Get-AutopatchHealth.ps1
|
<#PSScriptInfo
.VERSION 2.3.4 .GUID 2c80336a-7d9b-41c0-8eb3-a80abef2dbb8 .AUTHOR Jeff Gilbert .COMPANYNAME .COPYRIGHT .TAGS Intune .LICENSEURI .PROJECTURI .ICONURI .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES v2.3.4 - 4.14.26 - Registry check bug fixes v2.3.3 - 4.14.26 - Corrected telemetry check values; minor bug fixes v2.3.2 - 4.8.26 - Added HTML summary report and logging functionality. Hosted on GitHub for Get-Help -Online help. v2.2.0 - 4.3.26 - Added Windows Event log checks and report functionality v2.1.1 - 4.1.26 - Incorporated tester feedback and updated Get-Help documentation v2.0.1 - 3.25.26 - Removed deprecated network endpoint from connectivity checks v2.0.0 - 3.23.26 - Added more checks and function documentation v1.0.0 - 3.10.26 - Original published version #> <# .SYNOPSIS Validates Windows Autopatch and Feature Update readiness by performing comprehensive device configuration, policy, service, registry, network, and scheduled task health checks. .DESCRIPTION This script performs a read only, Windows Autopatch health assessment. It validates OS servicing branch, telemetry level, Intune enrollment and IME activity, co‑management workloads, Windows Update policy authority, WSUS or registry blockers, required services, network endpoints, and scheduled tasks. It runs safely in SYSTEM or user context and outputs console results, a readiness summary, and an exit code suitable for Intune detection/remediation. GENERAL CONFIGURATION HEALTH CHECKS Operating System Release Branch: Ensures the device is on a supported GA/production channel (not Windows Insiders/Preview). Registry Settings: Checks for Autopatch‑blocking or WSUS‑redirecting values: - HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate -- DoNotConnectToWindowsUpdateInternetLocations, DisableWindowsUpdateAccess, WUServer, WUStatusServer - HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU -- NoAutoUpdate - Update source override keys: -- SetPolicyDrivenUpdateSourceForDriverUpdates, FeatureUpdates, OtherUpdates, QualityUpdates If set to '1' and a WUSserver is configured, the device tries to use WSUS instead of Autopatch. Telemetry: Reads the AllowTelemetry policy value from the local registry to confirm minimum level = **1 (Required)**. Intune Enrollment & IME Activity: Validates Intune enrollment indicators and IME log activity within the last 28 days. For comanaged devices, checks required workload ownership: - Windows Update policies - Device configuration - Office Click to Run apps Update Policy Authority: Reads the PolicySources value to ensure Intune/Autopatch is configured: HKLM\SOFTWARE\Microsoft\WindowsUpdate\UpdatePolicy\PolicyState\PolicySources Values: 1=GPO, 2=SCCM, 4=Intune/Autopatch, 5=GPO+MDM (MDM wins), 6=SCCM+MDM (MDM wins). Also attempts to identify the Autopatch ring a device is assigned to (test, ring1, ring2, etc.) AUTOPATCH SERVICE HEALTH CHECKS Validates required Windows Update–related services: - BITS (downloads update payloads) - CryptSvc (signature validation) - DiagTrack (telemetry pipeline) - DoSvc (Delivery Optimization) - UsoSvc (update orchestration) - WaaSMedicSvc (repairs Windows Update stack) - wuauserv (core Windows Update client) NETWORK ENDPOINT CONNECTIVITY CHECKS Confirms reachability of required Autopatch and Microsoft endpoints: - mmdcustomer.microsoft.com (Microsoft Managed Desktop (MMD) / Windows Autopatch service endpoint) - mmdls.microsoft.com (part of the Autopatch logging and service communication layer) - login.windows.net (endpoint used to issue and refresh authentication tokens) - device.autopatch.microsoft.com (Service endpoint that must be reachable from Autopatch devices) - services.autopatch.microsoft.com (Service API endpoint that must be reachable for Autopatch functionality) - Global Device Listener: devicelistenerprod.microsoft.com (global Autopatch device listener communication endpoint required for Autopatch‑managed devices) - EU Device Listener: devicelistenprod.eudb.microsoft.com (EU tenants) (EU endpoint used instead of the global listener within the EU Data Boundary) WINDOWS UPDATE SCHEDULED TASK CHECKS Validates required scheduled tasks: - \Microsoft\Windows\WindowsUpdate\Scheduled Start (triggers scans/downloads) - \Microsoft\Windows\UpdateOrchestrator\Report policies (evaluates effective policy) WINDOWS UPDATE EVENT LOG CHECKS Scans device event logs for known Windows Update error code entries within the last 7 days (Edit $DaysBack to change time period): - Microsoft-Windows-WindowsUpdateClient/Operational - Microsoft-Windows-WindowsUpdateClient/Admin - Microsoft-Windows-UpdateOrchestrator/Operational - Microsoft-Windows-DeliveryOptimization/Operational .PARAMETER Remediation (Optional) Use this parameter to test the script's behavior as an Intune remediation: .\Get-AutopatchHealth.ps1 -Remediation There is also a line in the script's READ ME section to hard-code it to run as a remediation. Uncomment the line below (delete the #) in the READ ME section of the script: # $Remediation = $true. This will change the format of the pass/fail images from ✅/❌ to $($script:Symbols.Fail)$($script:Symbols.Pass)/$($script:Symbols.Fail) so they will display properly in the remediation pre-remediation detection output. If the script runs as a remedation (detection only), a log file will be generated at C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IR_AutopatchHealth_ERR.log. If subsequent remediation runs pass, the ERR log file will be deleted after it has aged over 14 days. .PARAMETER EU (Optional) Use this if running the script against an EU tenant to check network endpoint connectivity: .\Get-AutopatchHealth.ps1 -EU You can also modify the script to include EU data network boundaries in network tests. Uncomment the line below in the READ ME section of the script (delete the #): # $EU = $true .PARAMETER Report When the Report parameter is specified, a transcript of script activity is recorded at $env:WINDIR\Temp\AutopatchHealth_$env:COMPUTERNAME.log and an html report is automatically generated C:\Users\Public\Documents\<computername>.html. The report is automaticlaly opened in the default web browser when the script exits. .EXAMPLE Test running the script as an Intune remediation. If actually using it as a remediation script instead of just testing, you need to un-comment the $Remediation line in READ ME. This will also result in a log file beign retained in the IME logs location is there is a test failure. Modify the script invocation to: PS C:\> .\Get-AutopatchHealth.ps1 -Remediation .EXAMPLE Run network checks including EU Data Boundary endpoints: Modify the script invocation to: PS C:\> .\Get-AutopatchHealth.ps1 -EU .EXAMPLE Test running the script as an Intune remediation and run network checks including EU Data Boundary endpoints: Modify the script invocation to: PS C:\> .\Get-AutopatchHealth.ps1 -Remediation -EU .EXAMPLE Generate a report at the end of the health check script execution. Modify the script invocation to: PS C:\> .\Get-AutopatchHealth.ps1 -Report .NOTES This script aligns with Microsoft guidance for: - Windows Autopatch onboarding - Windows Update for Business (WUfB) - Delivery Optimization network requirements .LINK https://github.com/jeffgilb/Get-AutopatchHealth/blob/main/Get-AutopatchHealth.ps1 #> param ( [switch]$Remediation, # Test running the script as a remediation: Get-AutopatchHealth.ps1 -Remediation [switch]$EU, # Use to test EU Autopatch endpoint: Get-AutopatchHealth.ps1 -EU [switch]$Report # Use to create and open a summary report at script completion ) #-------------------------------------------------------- Functions --------------------------------------------------------- Function Use-NoUniCode { <# Use Unicdode symbols for console output running the script directly for the best visual experience. Do not use Unicode symbols for Intune remediations because the unicode symbols will not render as expected in the admin center. Use ASCII markers instead. Unicode gets stripped or mangled by the Intune reporting pipeline before the Admin Center renders it. Scripts are required to be UTF‑8 encoded and Intune uses a restricted text renderer that is effectively ASCII‑safe, not Unicode‑safe. Intune captures only standard output and that output is truncated to ~2048 characters. This is by design because remediation output is designed for machine status, CSV export, so Intune intentionally keeps this path plain‑text safe. #> if ($Remediation) { # NoUnicode $script:Symbols = @{ Pass = "[PASS]" Fail = "[FAIL]" Warn = "[WARN]" Info = "[INFO]" } } else { $script:Symbols = @{ Pass = "✅" Fail = "❌" Warn = "⚠️" Info = "ℹ️" } } } Function RU-Admin{ <# Determines if the current Windows identity is a member of the local Administrators group. If the session is not elevated, the function displays a warning indicating that running Autopatch health checks without administrative permissions can result in false‑negative reporting due to restricted access to system resources. #> $IsAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) If (!($IsAdmin)){ Write-Host "-------------------------------------------------" -ForegroundColor Red Write-Host "$($script:Symbols.Fail) When running Autopatch health checks with limited permissions (non-admin)`nYou WILL encounter false negative reporting results." -ForegroundColor Red Write-Host "-------------------------------------------------" -ForegroundColor Red $script:summary += " $($script:Symbols.Fail) Health checks not run as admin - expect inaccurate results" } } Function Test-Branch { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [String]$Branch ) $policyStatePath = 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\WindowsUpdate\UpdatePolicy\PolicyState' if (-not (Test-Path $policyStatePath)) { Write-Host "PolicyState registry path not found: $policyStatePath" -ForegroundColor Yellow return } $policyState = Get-ItemProperty -Path $policyStatePath -ErrorAction SilentlyContinue # ----------------------------- # BranchReadinessLevel # ----------------------------- $brl = [string]$policyState.BranchReadinessLevel $brlLabel = switch ($brl) { 'CB' { 'Current Branch' } 'B' { 'Business (SAC)' } 'RP' { 'Release Preview' } 'WIF' { 'Windows Insider Fast' } 'WIS' { 'Windows Insider Slow' } 'WIP' { 'Windows Insider Preview' } default { $brl } # fall back to raw value } If ($brl -eq $branch ){ Write-Host (" Operating System branch is {0} ({1})" -f $brl, $brlLabel) ; return $true } else { Write-Host (" $($script:Symbols.Fail) BranchReadinessLevel is NOT supported: {0} ({1})" -f $brl, $brlLabel) -ForegroundColor Red ; return $false } } Function Check-Registry { # ----------------------------- # AutopatchRegistryBlockerCheck # ----------------------------- $blockingKey = 0 $blockers = @( @{ Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' Name = 'DoNotConnectToWindowsUpdateInternetLocations' }, @{ Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' Name = 'DisableWindowsUpdateAccess' }, @{ Path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' Name = 'NoAutoUpdate' } ) foreach ($blocker in $blockers) { if (Get-ItemProperty -Path $blocker.Path -Name $blocker.Name -ErrorAction SilentlyContinue) { Write-Host " Autopatch device registration blocking registry key value found:`n $($blocker.Path)\$($blocker.Name)" -ForegroundColor Red ; $blockingKey++ } } if ($blockingKey -eq 0){ Write-Host " No Autopatch device registration blockers found" } # ----------------------------- # WSUS Server Keys Checks # ----------------------------- $SUPreg = 0 $WSrvKeys = @{ Path = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"; ValueName = "WUServer"; WSrvValue = $null }, @{ Path = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"; ValueName = "WUStatusServer"; WSrvValue = $null } foreach ($Key in $WSrvKeys) { $reg = Get-ItemProperty -Path $Key.Path -ErrorAction SilentlyContinue if ([string]::IsNullOrWhiteSpace($reg.$($Key.ValueName))) { Write-Host " $($Key.ValueName) key value not found or blank " } else { Write-Host " $($Key.ValueName) value is configured: $($reg.$($Key.ValueName))" -ForegroundColor Red ; $SUPreg++ } # WSUS config registry value found } # The update source key values only apply if the WUServer and/or WUStatusServer values are present [1 means WSUS, 0 means Autopatch] $UpdateSourceKeys = @( @{ Path = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"; ValueName = "SetPolicyDrivenUpdateSourceForDriverUpdates"; WSUSValue = "1" }, @{ Path = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"; ValueName = "SetPolicyDrivenUpdateSourceForFeatureUpdates"; WSUSValue = "1" }, @{ Path = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"; ValueName = "SetPolicyDrivenUpdateSourceForOtherUpdates"; WSUSValue = "1" }, @{ Path = "Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate"; ValueName = "SetPolicyDrivenUpdateSourceForQualityUpdates"; WSUSValue = "1" } ) ForEach ($Key in $UpdateSourceKeys){ $value = Get-ItemProperty -Path $Key.Path -Name $Key.ValueName -ErrorAction SilentlyContinue if ($null -ne $value){ if ([string]$value.$($Key.ValueName) -eq $Key.WSUSValue){ Write-Host " $($Key.ValueName) is set to 1 (WSUS)" -ForegroundColor Red ; $SUPreg++ } # WSUS config registry value found } else { Write-Host " No WSUS update source policy registry values found" } # Summary If ( $SUPreg -eq 0 -and $blockingKey -eq 0 ){ return $true } else { return $false} } } Function Test-Telemetry { [CmdletBinding()] param( [Parameter(Mandatory = $true)] [ValidateRange(0, 3)] [int]$minRequired ) $regPath = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' $valueName = 'AllowTelemetry_PolicyManager' # AllowTelemetry_PolicyManager is written by Intune telemetry policies implemented via the Policy CSP # (Settings Catalog, OMA‑URI, or Device Restrictions). It represents the authoritative MDM‑enforced # telemetry level and supersedes the legacy AllowTelemetry policy key. try { $val = (Get-ItemProperty -Path $regPath -Name $valueName -ErrorAction Stop).$valueName $val = [int]$val $map = @{ 0 = 'Diagnostic data off (Security)'; 1 = 'Required diagnostic data (Basic)'; 2 = '(Legacy) Enhanced'; 3 = 'Optional diagnostic data (Full)' } if ($val -ge $minRequired) { Write-Host " Telemetry is set to $($map[$val]) [$($val)]" ; return $true } else { return $false } } catch { Write-Host " $($script:Symbols.Fail) Policy value not found ($regPath\$valueName)." ; return $false } } Function Intune-Checks{ # ----------------------------- # MDMEnrollmentCheck # ----------------------------- $missingKey = 0 $paths = @( 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\IntuneManagementExtension', 'Registry::HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\PolicyManager\current\device' ) foreach ($p in $paths) { if (!(Test-Path $p)){ Write-Host " Enrollment key $p is NOT present" -ForegroundColor Red ; $missingKey++} } If ($missingKey -eq 0){ Write-Host " Intune enrollment indicators are present" } # ----------------------------- # IMEActivityCheck # ----------------------------- $stale = 0 $latestImeLog = Get-ChildItem 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs' ` -Filter '*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($latestImeLog) { $lastActivity = $latestImeLog.LastWriteTime $cutoffDate = (Get-Date).AddDays(-28) if ($lastActivity -ge $cutoffDate) { Write-Host " Last Intune activity (IME): $lastActivity" } else { Write-Host " STALE (device activity is older than 28 days)" -ForegroundColor Red ; $stale++ } } else { Write-Host " No Intune Management Extension logs found." -ForegroundColor Red } # ----------------------------- # ComanagementWorkloadCheck # ----------------------------- # Get ConfigMgr co-management state $ccm = Get-CimInstance -Namespace 'root\ccm\InvAgt' -ClassName 'CCM_System' -ErrorAction SilentlyContinue if ($ccm.CoManaged) { Write-Host " Co-management indicators are present, checking required workload ownership" # Autopatch-required workloads $RequiredWorkloads = @{ WindowsUpdatePolicies = 16 # Windows Update policies workload OfficeClickToRunApps = 128 # Office Click-to-Run apps workload DeviceConfiguration = 8 # Device configuration workload } $Missing = @() foreach ($workload in $RequiredWorkloads.GetEnumerator()) { if (-not ($ccm.ComgmtWorkloads -band $workload.Value)) { $Missing += $workload.Key } } if ($Missing.Count -eq 0) { Write-Host " Autopatch-required co-management workloads are owned by Intune " } else { Write-Host " The following workloads are NOT managed by Intune:" -ForegroundColor Red $Missing | ForEach-Object { Write-Host " - $_" -ForegroundColor Red } } } If ($missingKey -eq 0 -and $stale -eq 0 -and $Missing.Count -eq 0 ){ return $true } else { return $false } } Function Test-UpdateEngine{ [CmdletBinding()] param( [Parameter(Mandatory = $true)] [Int]$PolicySource ) $policySources = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UpdatePolicy\PolicyState' -ErrorAction Stop).PolicySources $srcLabel = switch ((Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UpdatePolicy\PolicyState').PolicySources) { 1 { 'GPO (Legacy WSUS)' } 2 { 'Configuration Manager' } 4 { 'Intune/Autopatch' } 5 { 'GPO + MDM (MDM wins)' } 6 { 'SCCM + MDM (MDM wins)' } default { 'Unknown / Not set' } } Write-Host " The active update engine is $($srcLabel)" # Determine if a device is in an Autopatch Group ring $ringErr = 0 $regPath = 'HKLM:\SOFTWARE\Microsoft\WindowsAutopatch\ClientBroker' $valueName = 'Ring' $ringValue = (Get-ItemProperty -Path $regPath -Name $valueName -ErrorAction SilentlyContinue).$valueName if ([string]::IsNullOrWhiteSpace($ringValue)) { Write-Host " This device is not assigned an Autopatch ring" -ForegroundColor Red ; $ringErr++ } else { Write-Host " Device is in an Autopatch ring: $($ringValue)" } if (($policySources -band 4) -and ($ringErr -eq 0)) { $true } else { $false } } function Get-AutopatchServiceStatus { [CmdletBinding()] param( [string[]] $ServiceNames ) $results = @() $svcIssue = 0 $total = $ServiceNames.Count $index = 0 foreach ($service in $ServiceNames) { $index++ $percent = [math]::Round(($index / $total) * 100, 0) Write-Progress ` -Activity "Checking Windows Update-related services" ` -Status "Checking $service ($index of $total)" ` -PercentComplete $percent $svc = Get-Service -Name $service -ErrorAction SilentlyContinue $cim = Get-CimInstance -ClassName Win32_Service -Filter "Name='$service'" -ErrorAction SilentlyContinue $issueReason = $null if (-not $cim){ $issueReason = 'Service not installed' ; $svcIssue++ } elseif ($cim.StartMode -eq 'Disabled'){ $issueReason = 'Service is disabled' ; $svcIssue++ } elseif ( $svc -and $svc.Status -ne 'Running' -and $cim.Name -in @('UsoSvc','DiagTrack','CryptSvc') ) { $issueReason = 'Service not running' ; $svcIssue++ } $results += [pscustomobject]@{ ServiceName = $service DisplayName = if ($cim) { $cim.DisplayName } else { '<Not Installed>' } Installed = [bool]$cim StartupType = if ($cim) { $cim.StartMode } else { $null } Status = if ($svc) { $svc.Status } else { $null } Issue = $issueReason } } Write-Progress -Activity "Checking Windows Update-related services" -Completed # Build health summary object [pscustomobject]@{ CheckName = 'Windows Update / Autopatch Services' TotalServices = $total HealthyServices = $total - $svcIssue ServicesWithIssues = $svcIssue OverallHealth = if ($svcIssue -eq 0) { 'Healthy' } else { 'Unhealthy' } Timestamp = (Get-Date) Services = $results | Sort-Object ServiceName IssueSummary = $results | Where-Object Issue | Group-Object Issue | Select-Object Name, Count } } Function Update-Events{ param ( [switch]$IncludeWarnings ) $startTime = (Get-Date).AddDays(-$DaysBack) $logs = @( 'Microsoft-Windows-WindowsUpdateClient/Operational', 'Microsoft-Windows-WindowsUpdateClient/Admin', 'Microsoft-Windows-UpdateOrchestrator/Operational', 'Microsoft-Windows-DeliveryOptimization/Operational' ) $levels = @(1,2) # Critical, Error if ($IncludeWarnings) { $levels += 3 } # --- Known bad Event IDs --- $KnownBadEventIds = @{ 20 = @{ Severity='High'; Category='Install'; Hint='Update installation failed. Check CBS.log and DISM health.' } 25 = @{ Severity='Medium'; Category='Scan'; Hint='Windows Update scan failed.' } 31 = @{ Severity='High'; Category='Download'; Hint='Update download failed. Check network or Delivery Optimization.' } 34 = @{ Severity='High'; Category='Install'; Hint='Update installation failed.' } 41 = @{ Severity='Critical'; Category='Servicing'; Hint='Servicing stack failure or reboot pending.' } 500 = @{ Severity='High'; Category='Orchestration'; Hint='Update orchestration failure.' } 501 = @{ Severity='Medium'; Category='Policy'; Hint='Update deferred or blocked by policy.' } 1006 = @{ Severity='Medium'; Category='DeliveryOptimization'; Hint='Content download failure.' } 1012 = @{ Severity='High'; Category='DeliveryOptimization'; Hint='Delivery Optimization network failure.' } 16398 = @{ Severity='High'; Category='BITS'; Hint='BITS job failure.' } } $events = foreach ($log in $logs) { if (Get-WinEvent -ListLog $log -ErrorAction SilentlyContinue) { Get-WinEvent -FilterHashtable @{ LogName = $log Level = $levels StartTime = $startTime } -ErrorAction SilentlyContinue | ForEach-Object { $classification = $KnownBadEventIds[$_.Id] [pscustomobject]@{ TimeCreated = $_.TimeCreated Level = $_.LevelDisplayName EventId = $_.Id Provider = $_.ProviderName LogName = $_.LogName Message = $_.Message KnownBad = [bool]$classification Severity = $classification.Severity Category = $classification.Category RemediationHint = $classification.Hint } } } } # --- Health summary --- $badEvents = $events | Where-Object KnownBad $summary = [pscustomobject]@{ CheckName = 'Windows Update Event Log Scan' TimeWindowDays = $DaysBack TotalEvents = $events.Count KnownBadEvents = $badEvents.Count CriticalEvents = ($badEvents | Where-Object Severity -eq 'Critical').Count OverallHealth = if ($badEvents.Count -eq 0) { 'Healthy' } else { 'Issues Found' } Timestamp = Get-Date FailureProfile = $badEvents | Group-Object Category | Select-Object Name, Count Events = $events | Sort-Object TimeCreated -Descending } $summary | Add-Member -MemberType NoteProperty -Name IsHealthy -Value ($badEvents.Count -eq 0) if (-not $summary.IsHealthy) { Write-Host " Known Windows Update failure events were detected in the last $DaysBack days" -ForegroundColor Red $summary.Events | Where-Object KnownBad | Format-List TimeCreated, EventId, Severity, Category, Message, RemediationHint, LogName } else { Write-Host " No known Windows Update failures detected in the last $DaysBack days" } return $summary } function Test-AutopatchNetworkConnection { [CmdletBinding()] param ( [switch]$IncludeEUDataBoundary, [int[]]$Ports = @(80,443), [int]$TimeoutSeconds = 5 ) $endpoints = @( @{ Name = "MMD Customer"; Host = "mmdcustomer.microsoft.com" } @{ Name = "MDM Download Service"; Host = "mmdls.microsoft.com" } @{ Name = "Azure AD Login"; Host = "login.windows.net" } @{ Name = "Autopatch Device Listener"; Host = "device.autopatch.microsoft.com" } @{ Name = "Autopatch Services"; Host = "services.autopatch.microsoft.com" } @{ Name = "Autopatch Payload Storage"; Host = "payloadprod1.blob.core.windows.net"; Wildcard = "payloadprod*.blob.core.windows.net" } @{ Name = "Windows Autopatch Device Listener"; Host = "devicelistenerprod.microsoft.com" } # Deprecated @{ Name = "Azure Web PubSub"; Host = "autopatch.webpubsub.azure.com"; Wildcard = "*.webpubsub.azure.com" } ) if ($IncludeEUDataBoundary) { $endpoints += @{ Name = "EU Data Boundary Device Listener" Host = "devicelistenprod.eudb.microsoft.com" } $endpoints = $endpoints | Where-Object { $_.Host -ne "devicelistenerprod.microsoft.com" } } $results = foreach ($ep in $endpoints) { $dnsResolved = $false $portResults = @{} $errorText = $null try { Resolve-DnsName -Name $ep.Host -ErrorAction Stop | Out-Null $dnsResolved = $true foreach ($port in $Ports) { $tcp = Test-NetConnection -ComputerName $ep.Host -Port $port -WarningAction SilentlyContinue $portResults["Port$port"] = $tcp.TcpTestSucceeded } } catch { $errorText = $_.Exception.Message foreach ($port in $Ports) { $portResults["Port$port"] = $false } } [PSCustomObject]@{ Name = $ep.Name Target = $ep.Host Wildcard = $ep.Wildcard DnsResolved = $dnsResolved Tcp80Success = $portResults["Port80"] Tcp443Success = $portResults["Port443"] Reachable = ($dnsResolved -and ($portResults.Values -contains $true)) Error = $errorText } } return $results } function Get-WindowsUpdateTaskStatus { [CmdletBinding()] param( [Parameter(Mandatory)] [string[]] $TaskPaths ) $taskIssue = 0 $results = foreach ($taskPath in $TaskPaths) { # Split full task path into folder + task name $taskName = Split-Path $taskPath -Leaf $taskFolder = Split-Path $taskPath -Parent $task = Get-ScheduledTask ` -TaskName $taskName ` -TaskPath ($taskFolder + '\') ` -ErrorAction SilentlyContinue $issueReason = $null if (-not $task) { $issueReason = 'Task not found' ; $taskIssue++ } elseif ($task.State -eq 'Disabled') { $issueReason = 'Task is disabled' ; $taskIssue++ } [pscustomobject]@{ TaskPath = $taskPath Exists = [bool]$task State = if ($task) { $task.State } else { $null } Issue = $issueReason } } if ($taskIssue -eq 0){ Write-Host "$($script:Symbols.Pass) All required Windows Update scheduled task(s) are healthy" -ForeGroundColor Green ; $script:summary+= "$($script:Symbols.Pass) All required Windows Update tasks are healthy" } else { Write-Host "$($script:Symbols.Fail) $taskIssue required Windows Update scheduled task(s) are missing or disabled" -ForeGroundColor Red ; $script:exitCode++ ; $script:summary+= "$($script:Symbols.Fail) Issue found with required Windows Update tasks" } # Output results for logging $results } function Build-Report { [CmdletBinding()] param () # ---------------------------- # Helper: object -> HTML table # ---------------------------- function Convert-ToHtmlTable { param ( [object[]]$Data, [string[]]$Columns ) if (-not $Data -or $Data.Count -eq 0) { return '<p><em>No data available</em></p>' } if ($Columns) { $Data = $Data | Select-Object $Columns } return ($Data | ConvertTo-Html -Fragment) } # ---------------------------- # CSS (wrap-safe, table-safe) # ---------------------------- $css = @" <style> body { font-family: Segoe UI, Arial; font-size: 13px; background-color: #f9f9f9; } h1 { background: #0078d4; color: white; padding: 12px; } h2 { color: #0078d4; border-bottom: 1px solid #ddd; } table { border-collapse: collapse; width: 100%; table-layout: fixed; } th, td { border: 1px solid #ddd; padding: 6px; word-break: break-word; white-space: normal; } th { background-color: #f2f2f2; } .pass { color: green; font-weight: bold; } .fail { color: red; font-weight: bold; } .warn { color: darkorange; font-weight: bold; } </style> "@ # ---------------------------- # Summary section # ---------------------------- $summaryHtml = "<ul>" + ($script:summary | ForEach-Object { "<li>$_</li>" }) + "</ul>" # ---------------------------- # Services table # ---------------------------- $servicesHtml = Convert-ToHtmlTable ` -Data $health.Services ` -Columns ServiceName, DisplayName, StartupType, Status, Issue # ---------------------------- # Network connectivity table # ---------------------------- $networkHtml = Convert-ToHtmlTable ` -Data $results ` -Columns Name, Target, Reachable # ---------------------------- # Scheduled tasks table # ---------------------------- $tasksHtml = Convert-ToHtmlTable ` -Data $taskResults ` -Columns TaskPath, Exists, State, Issue # ---------------------------- # Event log failures table # ---------------------------- $eventFailures = $wuEvents.Events | Where-Object KnownBad $eventsHtml = Convert-ToHtmlTable ` -Data $eventFailures ` -Columns TimeCreated, EventId, Severity, Category, Message, RemediationHint # ---------------------------- # Assemble HTML # ---------------------------- $html = @" <html> <head> <title>Autopatch Device Health Report</title> $css </head> <body> <h1>Autopatch Device Health Report</h1> <p> <b>Computer:</b> $env:COMPUTERNAME<br/> <b>User:</b> $env:USERNAME<br/> <b>Generated:</b> $(Get-Date)<br/> <b>Transcript:</b> $transcriptFile<br/> <b>Script Information:</b> <a href="https://www.powershellgallery.com/packages/Get-AutopatchHealth" target="_blank" rel="noopener noreferrer">Get-AutopatchHealth on the PowerShell Gallery</a> </p> <h2>Summary</h2> $summaryHtml <h2>Windows Update Services</h2> $servicesHtml <h2>Autopatch Network Connectivity</h2> $networkHtml <h2>Windows Update Scheduled Tasks</h2> $tasksHtml <h2>Windows Update Event Log Failures</h2> $eventsHtml </body> </html> "@ # ---------------------------- # Write + open report # ---------------------------- $html | Out-File -FilePath $reportFile -Encoding UTF8 Write-Host "HTML report created at $reportFile" -ForegroundColor Green if (-not $Remediation) { Invoke-Item $reportFile } } #----------------------------------------------------------- Prep ---------------------------------------------------------- # If we are running as a 32-bit process on an x64 system, re-launch as a 64-bit process first if ("$env:PROCESSOR_ARCHITEW6432" -ne "ARM64") { if (Test-Path "$($env:WINDIR)\SysNative\WindowsPowerShell\v1.0\powershell.exe") { & "$($env:WINDIR)\SysNative\WindowsPowerShell\v1.0\powershell.exe" -ExecutionPolicy bypass -NoProfile -File "$PSCommandPath" Exit $lastexitcode } } #**************************************************************************************************************************** # *** READ ME *** #**************************************************************************************************************************** # By default, these are $false unless you specify parameters on the command line or un-comment the lines below to force $true # Run script as remediation (detection only) #**************************************************************************************************************************** # Is this script supposed to run as an Intune remediation? If so, un-comment the following line (delete #) to set $Remediation to $true # This will force the script to use NoUniCode symbols and provide Intune Admin center friendly detecion output. #$Remediation = $true # <------ UNCOMMENT THIS LINE TO FORCE THE SCRIPT TO RUN IN REMEDIATION MODE # EU Network Data Endpoint Checks #**************************************************************************************************************************** # Are you running network tests on an EU tenant? If so, un-comment the line below to set $EU to $true #$EU = $true # <------ UNCOMMENT THIS LINE TO PERFORM EU TENANT NETWORK ENDPOINT TESTING # Summary report #**************************************************************************************************************************** # Do you want to see a cool HTML health summary after the script runs? Un-comment the line below to set $Report to $true #$Report = $true # <------ UNCOMMENT THIS LINE TO SAVE TRANSCRIPT AND BUILD HTML REPORT #**************************************************************************************************************************** # *** READ ME *** #**************************************************************************************************************************** #------------------------------------------------------- Begin Script ------------------------------------------------------- # If the script runs as a remedation (detection only), a log file will be generated at # C:\ProgramData\Microsoft\IntuneManagementExtension\Logs\IR_AutopatchHealth.log. If there are no issues, the log file will # automatically be deleted. If there are issues detected, the file will be renamed IR_AutopatchHealth_ERR.log. If subsequent # remediation passes all pass, the ERR log file will be deleted after it has aged over 14 days (gives you time to pull the log). If ($Remediation){ try { # This will fail if not run as admin $logPath = "$env:ProgramData\Microsoft\IntuneManagementExtension\Logs" $logFile = "IR_AutopatchHealth.log" $transcriptLogfile = Join-Path $logPath $logFile $errFile = Join-Path $logPath "IR_AutopatchHealth_ERR.log" Start-Transcript $transcriptLogFile } catch { <#Do this if a terminating exception happens#> } } if ($Report){ $transcriptFile = "$env:WINDIR\Temp\AutopatchHealth_$env:COMPUTERNAME.log" ; Start-Transcript $transcriptFile $reportFile = "$env:PUBLIC\Documents\AutopatchHealth_$env:COMPUTERNAME.html" } Clear-Host Write-Host "=================================================" -ForegroundColor Cyan Write-Host "| Autopatch Device Configuration Validation |" -ForegroundColor Cyan Write-Host "=================================================" -ForegroundColor Cyan Write-Host " Get-Help Get-AutopatchHealh `n" -Foregroundcolor Green $script:exitCode = 0 # Variable holding the number of failures detected $script:summary = @( ) # Arrary holding test results for summary $script:detectionOutput = @( ) # Arrary holding pre-remediation detection output to display in Intune admin center when this is run as a remediation # Decide which pass/fail markers to use in case we're running as a remediation Use-NoUniCode # If this is running as a remediation, use NoUniCode characters # ---------------------------- # R U Admin? # ---------------------------- RU-Admin # Some checks will fail if this script is not run as admin Write-Host "`nGeneral Autopatch Configuration Checks" -ForegroundColor Cyan Write-Host "================================================" # ---------------------------- # Operating System Release Branch # ---------------------------- Write-Host "Checking Operating System release channel" -ForegroundColor Yellow $releaseBranch = Test-Branch -Branch "CB" If ($releaseBranch -eq $true ){ Write-host "$($script:Symbols.Pass) OS Release channel is supported" -ForegroundColor Green ; $script:summary += " $($script:Symbols.Pass) Operating System is supported" } else { Write-Host "$($script:Symbols.Fail) Operating System release branch is not supported for Autopatch" -ForegroundColor Red ; $script:exitCode++ ;$script:summary += "$($script:Symbols.Fail) Operating System is not supported" } # ---------------------------- # Registry checks # ---------------------------- <# Registry values found at HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate only apply when Windows Update is managed by WSUS / GPO / ConfigMgr. When updates are managed by Intune, WUfB, or Windows Autopatch, Windows instead honors policies under HKLM\SOFTWARE\Microsoft\WindowsUpdate\UpdatePolicy\PolicyState, and legacy WSUS keys are ignored. #> Write-Host "Checking registry settings " -ForegroundColor Yellow If (Check-Registry -eq $true){ Write-Host "$($script:Symbols.Pass) No blocking or misconfigured WSUS registry values found" -ForegroundColor Green ; $script:summary+= "$($script:Symbols.Pass) No registry issues found" } else { Write-Host "$($script:Symbols.Fail) blocking or misconfigured registry values found" -ForegroundColor Red ; $script:exitCode++ ; $script:summary+= "$($script:Symbols.Fail) Registry checks failed" } # ---------------------------- # Telemetry # ---------------------------- <# Devices registered to Windows Autopatch must be configured to send at least “Required” Windows diagnostic data for Autopatch reports to accurately include device state and update status. #> try { write-host "Checking Telemetry Settings" -ForegroundColor Yellow $telemetryResult = Test-Telemetry -minRequired 1 # Security(0),Required/Basic(1),Enhanced(2),Optional/Full(3) if ( $telemetryResult) { Write-Host "$($script:Symbols.Pass) Telemetry is >= Required/Basic [1] "-ForegroundColor Green ; $script:summary+= "$($script:Symbols.Pass) Telemetry setting at or above miniumum required level" } else{ Write-Host "$($script:Symbols.Fail) Telemetry set below required minimum" -ForegroundColor Red ; $script:exitCode++ ; $script:summary+= "$($script:Symbols.Fail) Telemetry setting below miniumum required level" } } catch { # Do this if a terminating exception happens Write-Error "An error occurred: $_" } # ---------------------------- # Intune checks # ---------------------------- # Checks device is enrolled and has been active within the past 30 days Write-Host "Checking Intune enrollment and activity" -ForegroundColor Yellow If (intune-checks -eq $true) {Write-Host "$($script:Symbols.Pass) Device is enrolled correctly and active in Intune" -ForegroundColor Green ; $script:summary+= "$($script:Symbols.Pass) Device is enrolled correctly and active in Intune" } else { Write-Host "$($script:Symbols.Fail) Device is missing Intune enrollment or required co-management workloads" -ForegroundColor Red ; $script:exitCode++ ; $script:summary+= "$($script:Symbols.Fail) Device is missing Intune enrollment or required co-management workloads" } # ---------------------------- # Authoritative/active update engine (WSUS vs Microsoft Update) # ---------------------------- <# The authoritative way to determine which Windows Update engine is active is the PolicySources value at HKLM\SOFTWARE\Microsoft\WindowsUpdate\UpdatePolicy\PolicyState. If the value includes 4, the device is controlled by MDM (Intune / Autopatch), and legacy WSUS registry keys do not apply. #> Write-Host "Checking Update policy authority (single source of truth)" -ForegroundColor Yellow $updateEngine = Test-UpdateEngine -PolicySource 4 If ($updateEngine -eq $true ){ Write-host "$($script:Symbols.Pass) Device is configured for Intune/Autopatch updates" -ForegroundColor Green ; $script:summary+= "$($script:Symbols.Pass) Device is configured for Intune/Autopatch updates" } else { Write-Host "$($script:Symbols.Fail) Device is not configured for Intune/Autopatch updates" -ForegroundColor Red ; $script:exitCode++ ; $script:summary+= "$($script:Symbols.Fail) Device is not fully configured for Autopatch" } # ---------------------------- # Windows Update Services Checks # ---------------------------- Write-Host "`nWindows Update service status checks" -ForegroundColor Cyan Write-Host "================================================" Write-Host "Checking Autopatch (Windows Update) related services" -ForegroundColor Yellow try { $myServicesList = @( "WaaSMedicSvc", "wuauserv", "UsoSvc", "BITS", "DiagTrack", "DoSvc", "CryptSvc" ) $health = Get-AutopatchServiceStatus -ServiceNames $myServicesList if ($health.ServicesWithIssues -eq 0) { $message = "$($script:Symbols.Pass) All required services are healthy" Write-Host $Message -ForegroundColor Green ; $script:summary += $message} else { $message = "$($script:Symbols.Fail) Required services(s) issue found" Write-Host $Message -ForegroundColor Red ; $script:exitCode++ ; $script:summary += $message } # Show detailed table $health.Services | Format-Table -AutoSize } catch { Write-Error "An error occurred: $_" } # ---------------------------- # Autopatch Network Connectivity Checks # ---------------------------- try { Write-Host "`nAutopatch Network Connectivity Checks" -ForegroundColor Cyan Write-Host "================================================" Write-Host "Checking Autopatch & DO Network Connectivity Checks" -ForegroundColor Yellow # Use this line for non-EU tenants: If ( $EU ){ $results = Test-AutopatchNetworkConnection -IncludeEUDataBoundary } else { $results = Test-AutopatchNetworkConnection } if ($results.Reachable -contains $false) { Write-Host "$($script:Symbols.Fail) Some Autopatch network connectivity checks were unsuccessful" -ForegroundColor Red ; $script:exitCode++ ; $script:summary+= "$($script:Symbols.Fail) Autopatch network connectivity checks unsuccessful" } else{ Write-Host "$($script:Symbols.Pass) Autopatch network connectivity checks successful" -ForegroundColor Green ; $script:summary+= "$($script:Symbols.Pass) Autopatch network connectivity checks successful" } $results | Select-Object Name, Target, DnsResolved, Reachable | Format-Table -AutoSize } catch { Write-Error "An error occurred: $_" } # ---------------------------- # Windows Update Task Checks # ---------------------------- Write-Host "`nWindows Update scheduled task checks" -ForegroundColor Cyan Write-Host "================================================" Write-Host "Checking Windows Update related scheduled tasks" -ForegroundColor Yellow $windowsUpdateTasks = @( '\Microsoft\Windows\WindowsUpdate\Scheduled Start', '\Microsoft\Windows\UpdateOrchestrator\Report policies' ) try { $taskResults = Get-WindowsUpdateTaskStatus -TaskPaths $windowsUpdateTasks $taskResults | Format-Table } catch { Write-Error "An error occurred: $_" } # ---------------------------- # Windows Update Event Logs Checks # ---------------------------- Write-Host "`nWindows Update event log error checks" -ForegroundColor Cyan Write-Host "================================================" Write-Host "WindowsUpdateClient, UpdateOrchestrator, and DeliveryOptimization" -ForegroundColor Yellow $DaysBack = 7 # Number of days to check back in event logs for issues $wuEvents = Update-Events -DaysBack $DaysBack if ($wuEvents.IsHealthy){ $message = "$($script:Symbols.Pass) No Windows Update event log failures detected from the last $DaysBack days" Write-Host $message -ForegroundColor Green $script:summary += $message } else { $wuEvents.Events | Where-Object KnownBad | Format-List TimeCreated, EventId, Severity, Category, Message, RemediationHint, LogName | Out-Host $message = "$($script:Symbols.Fail) Windows Update event log failures detected" Write-Host $message -ForegroundColor Red ; $script:exitCode++ ; $script:summary += $message } # ---------------------------- # Summary # ---------------------------- Write-Host "`n==================================================" -ForegroundColor Cyan Write-Host "| Autopatch Device Health Summary |" -ForegroundColor Cyan Write-Host "==================================================" -ForegroundColor Cyan Write-Output ($script:summary -join " `n ") Write-Host "==================================================" -ForegroundColor Cyan ; Write-Host " Get-Help Get-AutopatchHealh `n" -Foregroundcolor Green # Remediation exit codes for Intune reporting if ($Remediation) { if ($script:exitCode -ge 1) { # Create ERR log file Stop-Transcript | Out-Null If (!(Test-Path $errFile )){ Rename-Item $transcriptLogfile $errFile -Force } else { Remove-Item $errFile -Force -ErrorAction SilentlyContinue Rename-Item $transcriptLogfile $errFile } # Create Intune Admin Center detection output $failSummary = @() $fails = $summary | Where-Object { $_ -match '\bFAIL\b' } # Get only failed entries if ($fails) { $fails | ForEach-Object { $failSummary += $_ } } # Add fails to detection output summary Write-Host ($failSummary -join " | ") # This is what's displayed in the Intune admin center's detection output # Remediation needed Exit 1 } else { Write-Output "[PASS] Healthy Autopatch configuration" # This is what's displayed in the Intune admin center's detection output Stop-Transcript | Out-Null Remove-Item $transcriptLogfile -Force -ErrorAction SilentlyContinue # Clean up old ERR logs if (Test-Path $errFile){ $retainDays = 14 $cutOff = (Get-Date).AddDays(-$retainDays) $errAge = (Get-Item $errFile).LastWriteTime if ( $errAge -lt $cutOff ){ Remove-Item $errFile -Force -ErrorAction SilentlyContinue } } # Remediation not needed Exit 0 } } If ($Report){ Stop-Transcript | Out-Null ; Build-Report } Exit |