Back to Scripts

Device Compliance Report

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.

DevicesComplianceReporting
Author: Ugur Koc
Version: 1.0
All Tests PassedTested on 08-02-2025
View on GitHub

Required Permissions

DeviceManagementManagedDevices.Read.All

Allows the app to read the properties of devices managed by Microsoft Intune, without a signed-in user.

DeviceManagementConfiguration.Read.All

Allows the app to read properties of Microsoft Intune-managed device configuration and device compliance policies and their assignment to groups, without a signed-in user.

get-device-compliance-report.ps1
<#
.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.0

.CHANGELOG
    1.0 - Initial release

.LASTUPDATE
    2025-05-29

.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
    Generates reports and forces module installation without prompting

.NOTES
    - Requires Microsoft.Graph.Authentication module: Install-Module Microsoft.Graph.Authentication
    - Requires appropriate permissions in Azure AD
    - 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.
#>

[CmdletBinding()]
param(
    [Parameter(Mandatory = $false, HelpMessage = "Output directory for the reports")]
    [string]$OutputPath = ".",
    
    [Parameter(Mandatory = $false, HelpMessage = "Open the HTML report after generation")]
    [switch]$OpenReport,
    
    [Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")]
    [switch]$ForceModuleInstall
)

# ============================================================================
# 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-Information "Running locally in IDE or terminal" -InformationAction Continue
    $IsAzureAutomation = $false
}

# Initialize required modules
$RequiredModules = @(
    "Microsoft.Graph.Authentication"
)

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 - Use interactive authentication
        Write-Information "Connecting to Microsoft Graph with interactive authentication..." -InformationAction Continue
        $Scopes = @(
            "DeviceManagementManagedDevices.Read.All",
            "DeviceManagementConfiguration.Read.All"
        )
        Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop
        Write-Information "✓ Successfully connected to Microsoft Graph" -InformationAction Continue
    }
}
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 ($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
            }
            Write-Warning "Error fetching data from $NextLink : $($_.Exception.Message)"
            break
        }
    } while ($NextLink)
    
    return $AllResults
}

# ============================================================================
# MAIN SCRIPT LOGIC
# ============================================================================

try {
    Write-Information "Starting device compliance report generation..." -InformationAction Continue

    # Get all managed devices
    Write-Information "Retrieving managed devices..." -InformationAction Continue
    $devices = Get-MgGraphAllPage -Uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices"
    Write-Information "✓ Found $($devices.Count) managed devices" -InformationAction Continue

    # Get compliance policies
    try {
        Write-Information "Retrieving compliance policies..." -InformationAction Continue
        $compliancePolicies = Get-MgGraphAllPage -Uri "https://graph.microsoft.com/v1.0/deviceManagement/deviceCompliancePolicies"
        Write-Information "✓ Found $($compliancePolicies.Count) compliance policies" -InformationAction Continue
    }
    catch {
        Write-Warning "Could not retrieve compliance policies: $($_.Exception.Message)"
        $compliancePolicies = @()
    }

    # Create report array
    $report = @()
    $processedCount = 0

    Write-Information "Processing device compliance data..." -InformationAction Continue

    foreach ($device in $devices) {
        $processedCount++
        Write-Progress -Activity "Processing Devices" -Status "Processing device $processedCount of $($devices.Count)" -PercentComplete (($processedCount / $devices.Count) * 100)
        
        try {
            # Get device compliance details
            $complianceUri = "https://graph.microsoft.com/v1.0/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)"
        }
    }

    Write-Progress -Activity "Processing Devices" -Completed

    # 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-Information "✓ CSV report saved: $csvPath" -InformationAction Continue
    }
    catch {
        Write-Error "Failed to save CSV report: $($_.Exception.Message)"
    }

    # Generate HTML report
    try {
        $htmlContent = @"
<!DOCTYPE html>
<html>
<head>
    <title>Intune Device Compliance Report</title>
    <style>
        body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 20px; background-color: #f5f5f5; }
        .header { background-color: #0078d4; color: white; padding: 20px; border-radius: 8px; margin-bottom: 20px; }
        .summary { background-color: white; padding: 15px; border-radius: 8px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
        .summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
        .summary-item { text-align: center; padding: 10px; background-color: #f8f9fa; border-radius: 4px; }
        .summary-number { font-size: 24px; font-weight: bold; color: #0078d4; }
        table { width: 100%; border-collapse: collapse; background-color: white; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
        th { background-color: #0078d4; color: white; padding: 12px; text-align: left; font-weight: 600; }
        td { padding: 10px 12px; border-bottom: 1px solid #e1e5e9; }
        tr:nth-child(even) { background-color: #f8f9fa; }
        tr:hover { background-color: #e3f2fd; }
        .compliant { color: #28a745; font-weight: bold; }
        .non-compliant { color: #dc3545; font-weight: bold; }
        .unknown { color: #6c757d; font-weight: bold; }
        .footer { margin-top: 20px; text-align: center; color: #6c757d; font-size: 12px; }
    </style>
</head>
<body>
    <div class="header">
        <h1>Intune Device Compliance Report</h1>
        <p>Generated on: $(Get-Date -Format "dddd, MMMM dd, yyyy 'at' HH:mm:ss")</p>
    </div>
    
    <div class="summary">
        <h2>Summary</h2>
        <div class="summary-grid">
            <div class="summary-item">
                <div class="summary-number">$($report.Count)</div>
                <div>Total Devices</div>
            </div>
            <div class="summary-item">
                <div class="summary-number">$(($report | Where-Object { $_.OverallCompliance -eq 'Compliant' }).Count)</div>
                <div>Compliant Devices</div>
            </div>
            <div class="summary-item">
                <div class="summary-number">$(($report | Where-Object { $_.OverallCompliance -eq 'Non-Compliant' }).Count)</div>
                <div>Non-Compliant Devices</div>
            </div>
            <div class="summary-item">
                <div class="summary-number">$(($report | Where-Object { $_.DaysSinceLastSync -ne 'Never' -and [double]$_.DaysSinceLastSync -gt 7 }).Count)</div>
                <div>Stale Devices (>7 days)</div>
            </div>
        </div>
    </div>
"@

        # Add table
        $htmlContent += "<table><thead><tr>"
        $htmlContent += "<th>Device Name</th><th>User</th><th>OS</th><th>Compliance Status</th><th>Compliant Policies</th><th>Non-Compliant Policies</th><th>Last Sync</th><th>Days Since Sync</th>"
        $htmlContent += "</tr></thead><tbody>"
        
        foreach ($device in $report | Sort-Object DeviceName) {
            $complianceClass = switch ($device.OverallCompliance) {
                "Compliant" { "compliant" }
                "Non-Compliant" { "non-compliant" }
                default { "unknown" }
            }
            
            $htmlContent += "<tr>"
            $htmlContent += "<td>$($device.DeviceName)</td>"
            $htmlContent += "<td>$($device.UserDisplayName)</td>"
            $htmlContent += "<td>$($device.OperatingSystem) $($device.OSVersion)</td>"
            $htmlContent += "<td class='$complianceClass'>$($device.OverallCompliance)</td>"
            $htmlContent += "<td>$($device.CompliantPolicies)</td>"
            $htmlContent += "<td>$($device.NonCompliantPolicies)</td>"
            $htmlContent += "<td>$($device.LastSyncDateTime)</td>"
            $htmlContent += "<td>$($device.DaysSinceLastSync)</td>"
            $htmlContent += "</tr>"
        }
        
        $htmlContent += "</tbody></table>"
        $htmlContent += "<div class='footer'>Report generated by Intune Device Compliance Script v1.0</div>"
        $htmlContent += "</body></html>"
        
        $htmlContent | Out-File -FilePath $htmlPath -Encoding UTF8
        Write-Information "✓ HTML report saved: $htmlPath" -InformationAction Continue
        
        if ($OpenReport -and -not $IsAzureAutomation) {
            Start-Process $htmlPath
        }
        
    }
    catch {
        Write-Error "Failed to generate HTML report: $($_.Exception.Message)"
    }

    # Display summary
    Write-Information "`n" -InformationAction Continue
    Write-Information "📊 COMPLIANCE REPORT SUMMARY" -InformationAction Continue
    Write-Information "================================" -InformationAction Continue
    Write-Information "Total Devices: $($report.Count)" -InformationAction Continue
    Write-Information "Compliant Devices: $(($report | Where-Object { $_.OverallCompliance -eq 'Compliant' }).Count)" -InformationAction Continue
    Write-Information "Non-Compliant Devices: $(($report | Where-Object { $_.OverallCompliance -eq 'Non-Compliant' }).Count)" -InformationAction Continue
    Write-Information "Unknown Status: $(($report | Where-Object { $_.OverallCompliance -eq 'Unknown' }).Count)" -InformationAction Continue
    Write-Information "Stale Devices (>7 days): $(($report | Where-Object { $_.DaysSinceLastSync -ne 'Never' -and [double]$_.DaysSinceLastSync -gt 7 }).Count)" -InformationAction Continue

    Write-Information "`nReports saved to:" -InformationAction Continue
    Write-Information "📄 CSV: $csvPath" -InformationAction Continue
    Write-Information "🌐 HTML: $htmlPath" -InformationAction Continue

    Write-Information "`n🎉 Device compliance report generation completed successfully!" -InformationAction Continue
}
catch {
    Write-Error "Script execution failed: $($_.Exception.Message)"
    exit 1
}
finally {
    # Disconnect from Microsoft Graph
    try {
        Disconnect-MgGraph | Out-Null
        Write-Information "✓ Disconnected from Microsoft Graph" -InformationAction Continue
    }
    catch {
        # Ignore disconnection errors - this is expected behavior when already disconnected
        Write-Verbose "Graph disconnection completed (may have already been disconnected)"
    }
}