Saltar al contenido
Microsoft scripts

Scripts de Intune

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

  1. Ejecuta el script en PowerShell 7 con -OutputPath hacia una carpeta de reportes.
  2. Inicia sesión como Administrador de Intune y espera a que termine (tenants grandes tardan varios minutos).
  3. Abre el HTML para ver el resumen y el CSV para filtrar por dispositivo.
  4. 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.

Relacionados