Reporte de cumplimiento de dispositivos
Genera un reporte CSV y HTML con el estado de cumplimiento de tus dispositivos administrados y estadísticas de resumen.
Ejemplo en México
Maquiladora electrónica en Tijuana, 600 personas
Cada lunes el gerente de planta pide cuántas laptops y tabletas de los tres turnos están fuera de cumplimiento antes de la reunión de seguridad.
.\get-device-compliance-report.ps1 -OutputPath "C:\Reportes" -OpenReport "true"Un HTML con el porcentaje de cumplimiento y el listado de equipos fuera de política. TI prioriza los del turno nocturno.
Cómo implementarlo
- Ejecuta el script en PowerShell 7 con -OutputPath hacia una carpeta de reportes.
- Inicia sesión como Administrador de Intune y espera a que termine (tenants grandes tardan varios minutos).
- Abre el HTML para ver el resumen y el CSV para filtrar por dispositivo.
- Atiende primero los dispositivos no compatibles y comparte el resumen con quien lo solicitó.
Ver el código completo de 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.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 = @"
<!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-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 "📊 COMPLIANCE REPORT SUMMARY"
Write-Output "================================"
Write-Output "Total Devices: $($report.Count)"
Write-Output "Compliant Devices: $(@($report | Where-Object { $_.OverallCompliance -eq 'Compliant' }).Count)"
Write-Output "Non-Compliant Devices: $(@($report | Where-Object { $_.OverallCompliance -eq 'Non-Compliant' }).Count)"
Write-Output "Unknown Status: $(@($report | Where-Object { $_.OverallCompliance -eq 'Unknown' }).Count)"
Write-Output "Stale Devices (>7 days): $(@($report | Where-Object { $_.DaysSinceLastSync -ne 'Never' -and [double]$_.DaysSinceLastSync -gt 7 }).Count)"
Write-Output "`nReports saved to:"
Write-Output "📄 CSV: $csvPath"
Write-Output "🌐 HTML: $htmlPath"
Write-Output "`n🎉 Device compliance report generation completed successfully!"
}
catch {
Write-Error "Script execution failed: $($_.Exception.Message)"
exit 1
}
finally {
# Disconnect from Microsoft Graph
try {
Disconnect-MgGraph | Out-Null
Write-Output "✓ Disconnected from Microsoft Graph"
}
catch {
# Ignore disconnection errors - this is expected behavior when already disconnected
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.