<# .TITLE Non-Compliant Devices with Reasons Report .SYNOPSIS Identify all non-compliant devices in Intune and the specific setting(s) that caused each failure. .DESCRIPTION This script connects to Microsoft Graph, retrieves every managed device whose compliance state is non-compliant (and optionally error / in grace period), then drills into each device's compliance policy states and the underlying setting states to surface the exact reason a device is non-compliant. For every failing setting it reports the owning policy, the setting name, the reported state, the current value, and any error code/description. Results are exported to both CSV (one row per failing setting) and a summary HTML report. .TAGS Compliance,Devices,Reporting .MINROLE Intune Administrator .PERMISSIONS DeviceManagementManagedDevices.Read.All,DeviceManagementConfiguration.Read.All .AUTHOR Ugur Koc .VERSION 1.4 .CHANGELOG 1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors 1.3 - Azure Automation now records script progress, outcomes, and summaries in job history 1.2 - Output directory is created automatically when missing; per-device and per-policy Graph calls are spaced with a short delay to reduce throttling; summary counts are wrapped in @() so single-result queries report accurate totals; progress bar output is suppressed in Azure Automation 1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing); report auto-open failures no longer abort the script 1.0 - Initial release .LASTUPDATE 2026-07-30 .EXAMPLE .\get-noncompliant-devices-with-reasons.ps1 Reports all non-compliant devices and the settings that caused the failure .EXAMPLE .\get-noncompliant-devices-with-reasons.ps1 -ComplianceStates noncompliant,error,inGracePeriod Includes devices in error and grace-period states in addition to non-compliant ones .EXAMPLE .\get-noncompliant-devices-with-reasons.ps1 -OutputPath "C:\Reports" -OpenReport "true" Saves the reports to the specified directory and opens the HTML report when finished .NOTES - Requires Microsoft.Graph.Authentication module: Install-Module Microsoft.Graph.Authentication - Requires appropriate permissions in Entra ID - Uses the Microsoft Graph beta endpoint to retrieve setting-level compliance detail - The CSV contains one row per failing setting; a device with multiple reasons appears on multiple rows - Devices flagged non-compliant with no setting-level detail (e.g. recently enrolled or not yet evaluated) are reported with a placeholder reason so they are not silently dropped - Large tenants may take several minutes: the script makes additional Graph calls per device - Disclaimer: This script is provided AS IS without warranty of any kind. Use it at your own risk. - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows #> [CmdletBinding()] param( [Parameter(Mandatory = $false, HelpMessage = "Compliance states to include in the report")] [ValidateSet("noncompliant", "error", "inGracePeriod", "conflict", "unknown")] [string[]]$ComplianceStates = @("noncompliant"), [Parameter(Mandatory = $false, HelpMessage = "Output directory for the reports")] [string]$OutputPath = ".", [Parameter(Mandatory = $false, HelpMessage = "Open the HTML report after generation")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$OpenReport, [Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$ForceModuleInstall ) # Normalize the local module-install override for Azure Automation parameter binding. $forceModuleInstallRaw = [string]$ForceModuleInstall Remove-Variable -Name ForceModuleInstall if ([string]::IsNullOrWhiteSpace($forceModuleInstallRaw)) { $ForceModuleInstall = $false } elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("true", "1", '$true')) { $ForceModuleInstall = $true } elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("false", "0", '$false')) { $ForceModuleInstall = $false } else { throw "Parameter 'ForceModuleInstall' accepts only true, false, 1, 0, $true, or $false." } # Azure Automation supplies portal parameter values as strings. Normalize the # public boolean parameters once so local and runbook execution use real booleans. foreach ($runbookBooleanParameter in @('OpenReport')) { $runbookBooleanRaw = [string](Get-Variable -Name $runbookBooleanParameter -ValueOnly) Remove-Variable -Name $runbookBooleanParameter if ([string]::IsNullOrWhiteSpace($runbookBooleanRaw)) { Set-Variable -Name $runbookBooleanParameter -Value $false continue } switch ($runbookBooleanRaw.Trim().ToLowerInvariant()) { { $_ -in @("true", "1", '$true') } { Set-Variable -Name $runbookBooleanParameter -Value $true } { $_ -in @("false", "0", '$false') } { Set-Variable -Name $runbookBooleanParameter -Value $false } default { throw "Parameter '$runbookBooleanParameter' accepts only true, false, 1, 0, $true, or $false." } } } # ============================================================================ # ENVIRONMENT DETECTION AND SETUP # ============================================================================ function Initialize-RequiredModule { <# .SYNOPSIS Ensures required modules are available and loaded #> param( [string[]]$ModuleNames, [bool]$IsAutomationEnvironment, [bool]$ForceInstall = $false ) foreach ($ModuleName in $ModuleNames) { Write-Verbose "Checking module: $ModuleName" $module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1 if (-not $module) { if ($IsAutomationEnvironment) { $errorMessage = @" Module '$ModuleName' is not available in this Azure Automation Account. To resolve this issue: 1. Go to Azure Portal 2. Navigate to your Automation Account 3. Go to 'Modules' > 'Browse Gallery' 4. Search for '$ModuleName' 5. Click 'Import' and wait for installation to complete Alternative: Use PowerShell to import the module: Import-Module Az.Automation Import-AzAutomationModule -AutomationAccountName "YourAccount" -ResourceGroupName "YourRG" -Name "$ModuleName" "@ throw $errorMessage } else { Write-Information "Module '$ModuleName' not found. Attempting to install..." -InformationAction Continue if (-not $ForceInstall) { $response = Read-Host "Install module '$ModuleName'? (Y/N)" if ($response -notmatch '^[Yy]') { throw "Module '$ModuleName' is required but installation was declined." } } try { $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") $scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" } Write-Information "Installing '$ModuleName' in scope '$scope'..." -InformationAction Continue Install-Module -Name $ModuleName -Scope $scope -Force -AllowClobber -Repository PSGallery Write-Information "Successfully installed '$ModuleName'" -InformationAction Continue } catch { throw "Failed to install module '$ModuleName': $($_.Exception.Message)" } } } try { Write-Verbose "Importing module: $ModuleName" Import-Module -Name $ModuleName -Force -ErrorAction Stop Write-Verbose "Successfully imported '$ModuleName'" } catch { throw "Failed to import module '$ModuleName': $($_.Exception.Message)" } } } # Detect execution environment if ($PSPrivateMetadata.JobId.Guid) { Write-Output "Running inside Azure Automation Runbook" $IsAzureAutomation = $true } else { Write-Output "Running locally in IDE or terminal" $IsAzureAutomation = $false } # Initialize required modules $RequiredModules = @( "Microsoft.Graph.Authentication" ) # MgGraphCommunity gives WAM-free interactive sign-in for local runs if (-not $IsAzureAutomation) { $RequiredModules += "MgGraphCommunity" } try { Initialize-RequiredModule -ModuleNames $RequiredModules -IsAutomationEnvironment $IsAzureAutomation -ForceInstall $ForceModuleInstall Write-Verbose "All required modules are available" } catch { Write-Error "Module initialization failed: $_" exit 1 } # ============================================================================ # AUTHENTICATION # ============================================================================ try { if ($IsAzureAutomation) { Write-Output "Connecting to Microsoft Graph using Managed Identity..." Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop Write-Output "Successfully connected to Microsoft Graph using Managed Identity" } else { Write-Output "Connecting to Microsoft Graph with interactive authentication..." $Scopes = @( "DeviceManagementManagedDevices.Read.All", "DeviceManagementConfiguration.Read.All" ) Connect-MgGraphCommunity -Scopes $Scopes -NoWelcome -ErrorAction Stop Write-Output "Successfully connected to Microsoft Graph" } } catch { Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)" exit 1 } # ============================================================================ # HELPER FUNCTIONS # ============================================================================ # Function to get all pages of results from Graph API function Get-MgGraphAllPage { param( [Parameter(Mandatory = $true)] [string]$Uri, [int]$DelayMs = 100 ) $AllResults = @() $NextLink = $Uri $RequestCount = 0 do { try { if ($RequestCount -gt 0) { Start-Sleep -Milliseconds $DelayMs } $Response = Invoke-MgGraphRequest -Uri $NextLink -Method GET $RequestCount++ if ($null -ne $Response.value) { $AllResults += $Response.value } else { $AllResults += $Response } $NextLink = $Response.'@odata.nextLink' } catch { if ($_.Exception.Message -like "*429*" -or $_.Exception.Message -like "*throttled*") { Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue Start-Sleep -Seconds 60 continue } throw "Error fetching data from $NextLink : $($_.Exception.Message)" } } while ($NextLink) return $AllResults } # HTML-encode a value for safe rendering in the report function ConvertTo-HtmlSafe { param([object]$Value) if ($null -eq $Value) { return "" } return [System.Web.HttpUtility]::HtmlEncode([string]$Value) } # ============================================================================ # MAIN SCRIPT LOGIC # ============================================================================ try { Write-Output "Starting non-compliant device reason report..." # Load System.Web for HTML encoding (available in Windows PowerShell and PowerShell 7+) Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue # Build a server-side filter for the requested compliance states $filterClause = ($ComplianceStates | ForEach-Object { "complianceState eq '$_'" }) -join " or " $deviceSelect = "id,deviceName,operatingSystem,osVersion,complianceState,userPrincipalName,userDisplayName,managedDeviceOwnerType,lastSyncDateTime,complianceGracePeriodExpirationDateTime" $devicesUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=$filterClause&`$select=$deviceSelect" Write-Output "Retrieving devices in state(s): $($ComplianceStates -join ', ')..." $devices = Get-MgGraphAllPage -Uri $devicesUri Write-Output "Found $($devices.Count) device(s) matching the requested compliance state(s)" $report = @() $processedCount = 0 foreach ($device in $devices) { $processedCount++ if (-not $IsAzureAutomation) { Write-Progress -Activity "Analyzing non-compliant devices" -Status "Device $processedCount of $($devices.Count): $($device.deviceName)" -PercentComplete (($processedCount / [math]::Max($devices.Count, 1)) * 100) } # Common device fields reused for every emitted row $gracePeriodExpiry = if ($device.complianceGracePeriodExpirationDateTime) { $device.complianceGracePeriodExpirationDateTime } else { $null } try { # Space out per-device calls to reduce throttling Start-Sleep -Milliseconds 100 $policyStatesUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$($device.id)')/deviceCompliancePolicyStates" $policyStates = Get-MgGraphAllPage -Uri $policyStatesUri # Only the policies that are not compliant contribute reasons $failingPolicies = $policyStates | Where-Object { $_.state -ne "compliant" -and $_.state -ne "notApplicable" } $deviceReasonCount = 0 foreach ($policy in $failingPolicies) { try { # Space out per-policy calls to reduce throttling Start-Sleep -Milliseconds 100 $settingStatesUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$($device.id)')/deviceCompliancePolicyStates/$($policy.id)/settingStates" $settingStates = Get-MgGraphAllPage -Uri $settingStatesUri # Surface only the settings that actually failed $failingSettings = $settingStates | Where-Object { $_.state -ne "compliant" -and $_.state -ne "notApplicable" } foreach ($setting in $failingSettings) { $deviceReasonCount++ # settingName is frequently null on beta; fall back to the setting identifier $reason = if (-not [string]::IsNullOrWhiteSpace($setting.settingName)) { $setting.settingName } else { $setting.setting } $sourcePolicy = if ($setting.sources -and $setting.sources.Count -gt 0) { ($setting.sources | ForEach-Object { $_.displayName } | Where-Object { $_ } | Select-Object -Unique) -join "; " } else { $policy.displayName } $report += [PSCustomObject]@{ DeviceName = $device.deviceName UserPrincipalName = $device.userPrincipalName UserDisplayName = $device.userDisplayName OperatingSystem = $device.operatingSystem OSVersion = $device.osVersion OwnerType = $device.managedDeviceOwnerType DeviceComplianceState = $device.complianceState PolicyName = $sourcePolicy PolicyState = $policy.state Reason = $reason SettingId = $setting.setting SettingState = $setting.state CurrentValue = $setting.currentValue ErrorCode = $setting.errorCode ErrorDescription = $setting.errorDescription GracePeriodExpiration = $gracePeriodExpiry LastSyncDateTime = $device.lastSyncDateTime DeviceId = $device.id } } } catch { Write-Warning "Error retrieving setting states for device '$($device.deviceName)' policy '$($policy.displayName)': $($_.Exception.Message)" } } # Device is flagged but no setting-level reason was returned - do not drop it silently if ($deviceReasonCount -eq 0) { $report += [PSCustomObject]@{ DeviceName = $device.deviceName UserPrincipalName = $device.userPrincipalName UserDisplayName = $device.userDisplayName OperatingSystem = $device.operatingSystem OSVersion = $device.osVersion OwnerType = $device.managedDeviceOwnerType DeviceComplianceState = $device.complianceState PolicyName = "(none reported)" PolicyState = ($failingPolicies | Select-Object -First 1 -ExpandProperty state -ErrorAction SilentlyContinue) Reason = "No setting-level detail reported (device may be recently enrolled, in grace period, or not yet evaluated)" SettingId = "" SettingState = "" CurrentValue = "" ErrorCode = "" ErrorDescription = "" GracePeriodExpiration = $gracePeriodExpiry LastSyncDateTime = $device.lastSyncDateTime DeviceId = $device.id } } } catch { Write-Warning "Error processing device '$($device.deviceName)': $($_.Exception.Message)" } } if (-not $IsAzureAutomation) { Write-Progress -Activity "Analyzing non-compliant devices" -Completed } # Create output directory if it doesn't exist if (-not (Test-Path $OutputPath)) { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null } # Generate timestamp for file names $timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" $csvPath = Join-Path $OutputPath "Intune_NonCompliant_Devices_Reasons_$timestamp.csv" $htmlPath = Join-Path $OutputPath "Intune_NonCompliant_Devices_Reasons_$timestamp.html" # Export to CSV try { $report | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 Write-Output "CSV report saved: $csvPath" } catch { Write-Error "Failed to save CSV report: $($_.Exception.Message)" } # Summary metrics $distinctDevices = @($report | Select-Object -ExpandProperty DeviceId -Unique).Count $totalReasons = @($report | Where-Object { $_.SettingId -ne "" }).Count # Top reasons across the estate $topReasons = $report | Where-Object { $_.SettingId -ne "" } | Group-Object Reason | Sort-Object Count -Descending | Select-Object -First 10 # Generate HTML report try { $htmlContent = @" Intune Non-Compliant Devices - Reasons Report

Intune Non-Compliant Devices - Reasons Report

Generated on: $(Get-Date -Format "dddd, MMMM dd, yyyy 'at' HH:mm:ss")

States included: $([System.Web.HttpUtility]::HtmlEncode(($ComplianceStates -join ', ')))

Summary

$distinctDevices
Affected Devices
$totalReasons
Total Failing Settings
"@ # Top reasons table if ($topReasons) { $htmlContent += "

Top Non-Compliance Reasons

" $htmlContent += "" foreach ($r in $topReasons) { $htmlContent += "" } $htmlContent += "
Reason / SettingDevice Count
$(ConvertTo-HtmlSafe $r.Name)$($r.Count)
" } # Detail table $htmlContent += "

Detail - One Row per Failing Setting

" $htmlContent += "" $htmlContent += "" $htmlContent += "" foreach ($row in $report | Sort-Object DeviceName, PolicyName, Reason) { $stateClass = switch ($row.SettingState) { "nonCompliant" { "state-noncompliant" } "error" { "state-error" } default { "" } } $errorText = if ($row.ErrorCode -and $row.ErrorCode -ne 0) { "$($row.ErrorCode): $($row.ErrorDescription)" } else { "" } $htmlContent += "" $htmlContent += "" $htmlContent += "" $htmlContent += "" $htmlContent += "" $htmlContent += "" $htmlContent += "" $htmlContent += "" $htmlContent += "" $htmlContent += "" } $htmlContent += "
DeviceUserOSPolicyReason / SettingStateCurrent ValueError
$(ConvertTo-HtmlSafe $row.DeviceName)$(ConvertTo-HtmlSafe $row.UserPrincipalName)$(ConvertTo-HtmlSafe ("{0} {1}" -f $row.OperatingSystem, $row.OSVersion))$(ConvertTo-HtmlSafe $row.PolicyName)$(ConvertTo-HtmlSafe $row.Reason)$(ConvertTo-HtmlSafe $row.SettingState)$(ConvertTo-HtmlSafe $row.CurrentValue)$(ConvertTo-HtmlSafe $errorText)
" $htmlContent += "" $htmlContent += "" $htmlContent | Out-File -FilePath $htmlPath -Encoding UTF8 Write-Output "HTML report saved: $htmlPath" if ($OpenReport -and -not $IsAzureAutomation) { try { Start-Process $htmlPath } catch { Write-Warning "Could not open the report automatically: $($_.Exception.Message)" } } } catch { Write-Error "Failed to generate HTML report: $($_.Exception.Message)" } # Display summary Write-Output "`n" Write-Output "NON-COMPLIANT DEVICE REASON SUMMARY" Write-Output "====================================" Write-Output "States included: $($ComplianceStates -join ', ')" Write-Output "Affected devices: $distinctDevices" Write-Output "Total failing settings: $totalReasons" if ($topReasons) { Write-Output "`nTop reasons:" foreach ($r in $topReasons) { Write-Output (" {0,-4} {1}" -f $r.Count, $r.Name) } } Write-Output "`nReports saved to:" Write-Output "CSV: $csvPath" Write-Output "HTML: $htmlPath" Write-Output "`nNon-compliant device reason report completed successfully." } catch { Write-Error "Script execution failed: $($_.Exception.Message)" exit 1 } finally { try { Disconnect-MgGraph | Out-Null Write-Output "Disconnected from Microsoft Graph" } catch { Write-Verbose "Graph disconnection completed (may have already been disconnected)" } } # Copia sin cambios de https://github.com/ugurkocde/IntuneAutomation (commit 797bb2e). # MIT License # # Copyright (c) 2025 Ugur Koc | Microsoft MVP # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE.