Public/Get-IntuneAnomaliesReport.ps1

<#
.SYNOPSIS
    Generates an interactive HTML report of Intune anomalies (app failures, multi-user devices, encryption, Autopilot, compliance, etc.).
    Connect first with Connect-RKGraph; this cmdlet uses the existing connection.
#>

function Get-IntuneAnomaliesReport {
[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)] [switch] $SendEmail,
    [Parameter(Mandatory = $false)] [string[]] $Recipient,
    [Parameter(Mandatory = $false)] [string] $From,
    [Parameter(Mandatory = $false)] [string] $ExportPath,
    # Surface devices that were *deliberately* excluded from BitLocker / LAPS policies
    # (via exclude group or assignment filter) as Info-severity rows. Off by default
    # so the report stays focused on actual anomalies; intentional exclusions are
    # admin choices, not gaps.
    [Parameter(Mandatory = $false)] [switch] $ShowExcludedDevices,
    [Parameter(Mandatory = $false)] [switch] $DebugMode
)

$ErrorActionPreference = 'Stop'
try {
    $swReport = [System.Diagnostics.Stopwatch]::StartNew()
    $swPhase  = [System.Diagnostics.Stopwatch]::new()

    $ctx = Get-MgContext -ErrorAction SilentlyContinue
    if (-not $ctx) { throw 'Not connected to Microsoft Graph. Run Connect-RKGraph first.' }

    $tenantInfo = Invoke-MgGraphRequest -Uri 'beta/organization' -Method Get -OutputType PSObject
    $tenantname = $tenantInfo.value[0].displayName

    $swPhase.Restart()
    $AllEntraIDUsers = Invoke-GraphRequestWithPaging -Uri "https://graph.microsoft.com/beta/users/?`$select=id,userPrincipalName,userType,accountEnabled" | Where-Object { $_.UserType -eq 'Member' }
    $DisabledEntraUsers = $AllEntraIDUsers | Where-Object { $_.accountEnabled -eq $false } | Select-Object id, userPrincipalName, userType, accountEnabled
    $swPhase.Stop(); Write-Verbose ("[Report] Entra users fetch: {0:N2}s ({1} members)" -f $swPhase.Elapsed.TotalSeconds, $AllEntraIDUsers.Count)

    Write-Host 'Starting device data collection...' -ForegroundColor Yellow
    $swPhase.Restart()
    $DeviceData = Get-AllDeviceData
    $swPhase.Stop(); Write-Verbose ("[Report] Get-AllDeviceData: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds)

    $swPhase.Restart()
    $AutopilotProfilesInformation = Get-AutopilotProfilesInformation
    $UserDrivenAutopilotProfiles = $AutopilotProfilesInformation | Where-Object { $_.outOfBoxExperienceSettings.deviceUsageType -eq 'SingleUser' }
    $swPhase.Stop(); Write-Verbose ("[Report] Autopilot profiles: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds)

    Write-Host 'Resolving BitLocker / Windows LAPS policy assignments and key escrow state...' -ForegroundColor Yellow
    $swPhase.Restart()
    $SecurityKeyContext = Get-BitLockerLapsAssignmentContext -DebugMode:$DebugMode
    $swPhase.Stop(); Write-Verbose ("[Report] BitLocker/LAPS context: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds)

    $swPhase.Restart()
    $Report_BitLockerStatus    = Resolve-IntuneBitLockerAnomalies   -Devices $DeviceData -Context $SecurityKeyContext -TenantName $tenantname -ShowExcludedDevices:$ShowExcludedDevices
    $Report_LapsStatus         = Resolve-IntuneLapsAnomalies        -Devices $DeviceData -Context $SecurityKeyContext -TenantName $tenantname -ShowExcludedDevices:$ShowExcludedDevices
    $Report_DeprecatedSettings = Resolve-IntuneDeprecatedAnomalies  -Context $SecurityKeyContext -TenantName $tenantname
    $swPhase.Stop(); Write-Verbose ("[Report] BitLocker + LAPS + Deprecated resolution: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds)

    $swPhase.Restart()
    $Report_ApplicationFailureReport = Get-ApplicationFailures
    $swPhase.Stop(); Write-Verbose ("[Report] Get-ApplicationFailures: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds)
    $Report_DevicesWithMultipleUsers = $DeviceData | Where-Object { $_.usersLoggedOnCount -gt 1 -and $_.EnrollmentProfile -in $UserDrivenAutopilotProfiles.displayName } | Select-Object Customer, DeviceName, PrimaryUser, EnrollmentProfile, usersLoggedOnCount, usersLoggedOnIds
    $Report_OperatingSystemEditionOverview = $DeviceData | Select-Object Customer, DeviceName, PrimaryUser, OperatingSystemEdition, OSFriendlyname
    # Not Encrypted is now folded into the BitLocker tab - unencrypted devices
    # show up there as 'Device not encrypted and no BitLocker policy assigned' or
    # 'BitLocker policy assigned but device not encrypted' so we don't need a
    # standalone tab anymore.
    $Report_DevicesWithoutAutopilotHash = $DeviceData | Where-Object { $_.DeviceHashUploaded -eq $false } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel
    $Report_InactiveDevices = $DeviceData | Where-Object { $_.LastContact -lt (Get-Date).AddDays(-90) } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel, LastContact
    $Report_DisabledPrimaryUsers = $DeviceData | Where-Object { $_.PrimaryUser -in $DisabledEntraUsers.userPrincipalName } | Select-Object Customer, DeviceName, PrimaryUser, Serialnumber, DeviceManufacturer, DeviceModel

    $Report_NoncompliantDevices = [System.Collections.Generic.List[PSObject]]::new()
    $NoncompliantDevicesRaw = $DeviceData | Where-Object { $_.ComplianceStatus -eq 'noncompliant' }
    foreach ($device in $NoncompliantDevicesRaw) {
        if ($device.NoncompliantBasedOn) {
            $reasons = $device.NoncompliantBasedOn -split ', '
            foreach ($reason in $reasons) {
                if ($reason.Trim()) {
                    $Report_NoncompliantDevices.Add([PSCustomObject]@{ Customer = $device.Customer; DeviceName = $device.DeviceName; PrimaryUser = $device.PrimaryUser; Serialnumber = $device.Serialnumber; DeviceManufacturer = $device.DeviceManufacturer; DeviceModel = $device.DeviceModel; ComplianceStatus = $device.ComplianceStatus; NoncompliantBasedOn = $reason.Trim(); NoncompliantAlert = $device.NoncompliantAlert })
                }
            }
        } else {
            $Report_NoncompliantDevices.Add([PSCustomObject]@{ Customer = $device.Customer; DeviceName = $device.DeviceName; PrimaryUser = $device.PrimaryUser; Serialnumber = $device.Serialnumber; DeviceManufacturer = $device.DeviceManufacturer; DeviceModel = $device.DeviceModel; ComplianceStatus = $device.ComplianceStatus; NoncompliantBasedOn = 'Unknown'; NoncompliantAlert = $device.NoncompliantAlert })
        }
    }

    $swPhase.Restart()
    New-IntuneAnomaliesHTMLReport -TenantName $tenantname -Report_ApplicationFailureReport $Report_ApplicationFailureReport -Report_DevicesWithMultipleUsers $Report_DevicesWithMultipleUsers -Report_DevicesWithoutAutopilotHash $Report_DevicesWithoutAutopilotHash -Report_InactiveDevices $Report_InactiveDevices -Report_NoncompliantDevices $Report_NoncompliantDevices -Report_OperatingSystemEditionOverview $Report_OperatingSystemEditionOverview -Report_DisabledPrimaryUsers $Report_DisabledPrimaryUsers -Report_BitLockerStatus $Report_BitLockerStatus -Report_LapsStatus $Report_LapsStatus -Report_DeprecatedSettings $Report_DeprecatedSettings -ExportPath $ExportPath
    $swPhase.Stop(); Write-Verbose ("[Report] HTML build: {0:N2}s" -f $swPhase.Elapsed.TotalSeconds)
    $swReport.Stop(); Write-Verbose ("[Report] TOTAL Get-IntuneAnomaliesReport: {0:N2}s" -f $swReport.Elapsed.TotalSeconds)

    $emailSent = $false
    if ($SendEmail -and $Recipient) {
        $subject = "$tenantname - Intune Anomalies Report"
        $bodyHtml = "<html><body style=`"font-family: Segoe UI, Arial, sans-serif;`"><h2>Intune Anomalies Report</h2><p>Attached is the latest Intune anomalies report for <strong>$tenantname</strong>.</p><p>Open the attached HTML in a browser for the interactive dashboard.</p><p style='color:#666;'>Generated by RKSolutions - please do not reply.</p></body></html>"
        $emailSent = Send-EmailWithAttachment -Recipient $Recipient -AttachmentPath $script:ExportPath -From $From -Subject $subject -BodyHtml $bodyHtml
        if ($emailSent) { Write-Host 'INFO: Email sent successfully.' -ForegroundColor Green } else { Write-Host 'ERROR: Failed to send email.' -ForegroundColor Red }
    }
    if ($SendEmail -and $emailSent -and (Test-Path -Path $script:ExportPath)) { Remove-Item -Path $script:ExportPath -Force; Write-Host 'INFO: Temporary report file deleted.' -ForegroundColor Green }
}
catch { Write-Error "Error: $_"; throw $_ }
finally {
    # Session left connected; use Disconnect-RKGraph when done.
}
}