# .TITLE Device Compliance Report .SYNOPSIS Generate a comprehensive device compliance report for managed devices in Intune. .DESCRIPTION This script connects to Microsoft Graph, retrieves managed devices and their compliance status, and generates a detailed report in both CSV and HTML formats. The report includes device details, compliance status, and summary statistics. .TAGS Devices,Compliance,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 - Managed device query now requests only the fields used by the report; output directory is created automatically when missing; per-device policy state calls are spaced with a short delay to reduce throttling; policy-state and 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-device-compliance-report.ps1 Generates compliance reports for all managed devices .EXAMPLE .\get-device-compliance-report.ps1 -OutputPath "C:\Reports" Generates reports and saves them to the specified directory .EXAMPLE .\get-device-compliance-report.ps1 -ForceModuleInstall "true" Generates reports and forces module installation without prompting .NOTES - Requires Microsoft.Graph.Authentication module: Install-Module Microsoft.Graph.Authentication - Requires appropriate permissions in Entra ID - Large tenants may take several minutes to complete - Reports are saved in both CSV and HTML formats - 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 = "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" # Check if module is available $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 { # Local environment - attempt to install 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 { # Check if running as administrator for AllUsers scope $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)" } } } # Import the module 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) { # Azure Automation - Use Managed Identity 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 { # Local execution - WAM-free interactive sign-in via MgGraphCommunity 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 { # Add delay to respect rate limits 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 } # ============================================================================ # MAIN SCRIPT LOGIC # ============================================================================ try { Write-Output "Starting device compliance report generation..." # Get all managed devices (only the fields used by the report) Write-Output "Retrieving managed devices..." $deviceSelect = "id,deviceName,userPrincipalName,userDisplayName,operatingSystem,osVersion,model,manufacturer,serialNumber,lastSyncDateTime,enrolledDateTime,managementState,managedDeviceOwnerType,complianceGracePeriodExpirationDateTime" $devices = Get-MgGraphAllPage -Uri "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=$deviceSelect" Write-Output "✓ Found $($devices.Count) managed devices" # Get compliance policies try { Write-Output "Retrieving compliance policies..." $compliancePolicies = Get-MgGraphAllPage -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies" Write-Output "✓ Found $($compliancePolicies.Count) compliance policies" } catch { Write-Warning "Could not retrieve compliance policies: $($_.Exception.Message)" $compliancePolicies = @() } # Create report array $report = @() $processedCount = 0 Write-Output "Processing device compliance data..." foreach ($device in $devices) { $processedCount++ if (-not $IsAzureAutomation) { Write-Progress -Activity "Processing Devices" -Status "Processing device $processedCount of $($devices.Count)" -PercentComplete (($processedCount / $devices.Count) * 100) } try { # Space out per-device calls to reduce throttling Start-Sleep -Milliseconds 100 # Get device compliance details $complianceUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices('$($device.id)')/deviceCompliancePolicyStates" $deviceCompliance = Get-MgGraphAllPage -Uri $complianceUri # Calculate compliance summary $compliantPolicies = @($deviceCompliance | Where-Object { $_.state -eq "compliant" }).Count $nonCompliantPolicies = @($deviceCompliance | Where-Object { $_.state -eq "nonCompliant" }).Count $errorPolicies = @($deviceCompliance | Where-Object { $_.state -eq "error" }).Count $totalPolicies = @($deviceCompliance).Count # Determine overall compliance status $overallCompliance = if ($nonCompliantPolicies -gt 0 -or $errorPolicies -gt 0) { "Non-Compliant" } elseif ($compliantPolicies -gt 0) { "Compliant" } else { "Unknown" } # Calculate days since last sync $daysSinceSync = if ($device.lastSyncDateTime) { [math]::Round(((Get-Date) - [DateTime]$device.lastSyncDateTime).TotalDays, 1) } else { "Never" } # Create device report object $deviceInfo = [PSCustomObject]@{ DeviceName = $device.deviceName UserPrincipalName = $device.userPrincipalName UserDisplayName = $device.userDisplayName OperatingSystem = $device.operatingSystem OSVersion = $device.osVersion Model = $device.model Manufacturer = $device.manufacturer SerialNumber = $device.serialNumber OverallCompliance = $overallCompliance CompliantPolicies = $compliantPolicies NonCompliantPolicies = $nonCompliantPolicies ErrorPolicies = $errorPolicies TotalPolicies = $totalPolicies LastSyncDateTime = $device.lastSyncDateTime DaysSinceLastSync = $daysSinceSync EnrolledDateTime = $device.enrolledDateTime ManagementState = $device.managementState OwnerType = $device.managedDeviceOwnerType ComplianceGracePeriodExpirationDateTime = $device.complianceGracePeriodExpirationDateTime DeviceId = $device.id } $report += $deviceInfo } catch { Write-Warning "Error processing device $($device.deviceName): $($_.Exception.Message)" } } if (-not $IsAzureAutomation) { Write-Progress -Activity "Processing 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_Device_Compliance_Report_$timestamp.csv" $htmlPath = Join-Path $OutputPath "Intune_Device_Compliance_Report_$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)" } # Generate HTML report try { $htmlContent = @"
Generated on: $(Get-Date -Format "dddd, MMMM dd, yyyy 'at' HH:mm:ss")
| Device Name | User | OS | Compliance Status | Compliant Policies | Non-Compliant Policies | Last Sync | Days Since Sync | " $htmlContent += "
|---|---|---|---|---|---|---|---|
| $($device.DeviceName) | " $htmlContent += "$($device.UserDisplayName) | " $htmlContent += "$($device.OperatingSystem) $($device.OSVersion) | " $htmlContent += "$($device.OverallCompliance) | " $htmlContent += "$($device.CompliantPolicies) | " $htmlContent += "$($device.NonCompliantPolicies) | " $htmlContent += "$($device.LastSyncDateTime) | " $htmlContent += "$($device.DaysSinceLastSync) | " $htmlContent += "