Atliso-NoncompliantDeviceAudit.ps1
|
<#PSScriptInfo .VERSION 1.0.0 .GUID 6e8c6c5a-9c73-49f8-ad34-acb821957c97 .AUTHOR Atliso .COMPANYNAME Atliso .COPYRIGHT (c) 2026 Atliso. MIT License. .TAGS Intune Compliance Noncompliant GracePeriod ConditionalAccess Graph Audit ReadOnly .LICENSEURI https://github.com/Atliso-Dev/noncompliant-device-audit/blob/main/LICENSE .PROJECTURI https://atliso.com/blog/intune-noncompliant-devices-report .REQUIREDMODULES Microsoft.Graph.Authentication .EXTERNALMODULEDEPENDENCIES .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES First release. #> <# .SYNOPSIS Atliso Noncompliant Device Audit. Read-only. .DESCRIPTION Lists every Intune device that is non-compliant, in its grace period, or in error, with the policy and setting that fails, when the grace period ends (Conditional Access blocks the user from then), and whether Intune has an email address to warn the user. Also shows what each compliance policy does when a device fails, and who it tells. Exports a CSV and exits 1 when anything is found. Changes nothing in your tenant. https://atliso.com/blog/intune-noncompliant-devices-report .PARAMETER TenantId Optional. A tenant ID or domain to sign in to, for MSPs running it against customer tenants. .NOTES Requires Microsoft.Graph.Authentication. Scopes: DeviceManagementManagedDevices.Read.All, DeviceManagementConfiguration.Read.All Exit 0 = every device compliant. Exit 1 = non-compliant, grace period or error devices found. #> [CmdletBinding()] param( [string]$TenantId ) # ===== CONFIG ===== $OutputPath = Join-Path $env:USERPROFILE 'Desktop\Atliso-NoncompliantDevices.csv' $CheckSettings = $true # look up the failing setting per device: one extra Graph call per failing policy $ShowPolicies = $true # list what each compliance policy does on failure, and who it tells $LogPath = Join-Path $env:LOCALAPPDATA 'Atliso\NoncompliantDeviceAudit.log' # ================== $ErrorActionPreference = 'Stop' function Write-Log([string]$Message) { try { $dir = Split-Path -Path $LogPath -Parent if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } Add-Content -Path $LogPath -Value ("{0} {1}" -f (Get-Date -Format s), $Message) } catch { } } # HTTP status from a Graph error. PowerShell 7 and Windows PowerShell 5.1 throw different exception shapes. function Get-HttpStatus($ErrorRecord) { $ex = $ErrorRecord.Exception try { if ($ex.Response -and $ex.Response.StatusCode) { return [int]$ex.Response.StatusCode } } catch { } if ([string]$ex.Message -match '\b(403|404|429)\b') { return [int]$Matches[1] } return $null } function Invoke-Graph([string]$Uri) { for ($try = 1; $try -le 5; $try++) { try { return Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject } catch { $status = Get-HttpStatus $_ if ($status -eq 429 -and $try -lt 5) { $wait = 10 try { $ra = $_.Exception.Response.Headers.RetryAfter.Delta; if ($ra) { $wait = [int][math]::Ceiling($ra.TotalSeconds) } } catch { } Write-Log "429 on $Uri, waiting $wait s" Start-Sleep -Seconds $wait continue } throw } } } function Get-GraphList([string]$Uri) { $items = New-Object System.Collections.Generic.List[object] while ($Uri) { $page = Invoke-Graph $Uri if (-not $page) { break } foreach ($v in @($page.value)) { if ($null -ne $v) { $items.Add($v) } } $Uri = $page.'@odata.nextLink' } return ,$items } # Graph returns 0001-01-01 or 9999-12-31 for dates that were never set. Treat those as no date. function ConvertTo-UtcDate($Value) { if (-not $Value) { return $null } if ($Value -is [datetime]) { $d = $Value.ToUniversalTime() } else { $d = ([datetimeoffset]::Parse([string]$Value, [Globalization.CultureInfo]::InvariantCulture)).UtcDateTime } if ($d.Year -lt 2000 -or $d.Year -gt 9000) { return $null } return $d } $now = (Get-Date).ToUniversalTime() function Format-Date($Date) { if ($Date) { $Date.ToString('yyyy-MM-dd HH:mm') + ' UTC' } else { '' } } # Plain names for the settings that fail most often. Anything else shows its Graph name without the policy prefix. $SettingNames = @{ 'RequireRemainContact' = 'Is active: no check-in within the validity period' 'RequireDeviceCompliancePolicyAssigned' = 'No compliance policy assigned' 'RequireUserExistence' = 'Enrolled user no longer exists' 'OsMinimumVersion' = 'OS older than the minimum version' 'OsMaximumVersion' = 'OS newer than the maximum version' 'MobileOsMinimumVersion' = 'OS older than the minimum version' 'ValidOperatingSystemBuildRanges' = 'OS build outside the allowed ranges' 'BitLockerEnabled' = 'BitLocker not on' 'StorageRequireEncryption' = 'Storage not encrypted' 'SecureBootEnabled' = 'Secure Boot off' 'CodeIntegrityEnabled' = 'Code integrity off' 'TpmRequired' = 'No TPM' 'ActiveFirewallRequired' = 'Firewall off' 'DefenderEnabled' = 'Microsoft Defender off' 'RtpEnabled' = 'Defender real-time protection off' 'SignatureOutOfDate' = 'Defender signatures out of date' 'AntivirusRequired' = 'No antivirus registered' 'AntiSpywareRequired' = 'No antispyware registered' 'DeviceThreatProtectionEnabled' = 'Defender for Endpoint risk too high' 'PasswordRequired' = 'No password' 'PasswordMinimumLength' = 'Password too short' 'PasswordRequiredType' = 'Password type not allowed' 'DeviceCompliancePolicyScript' = 'Custom compliance script failed' } function Get-SettingName([string]$Setting) { $short = ($Setting -split '\.')[-1] if ($SettingNames.ContainsKey($short)) { return $SettingNames[$short] } return $short } Write-Log 'Run started' $connect = @{ Scopes = @('DeviceManagementManagedDevices.Read.All', 'DeviceManagementConfiguration.Read.All'); NoWelcome = $true } if ($TenantId) { $connect.TenantId = $TenantId } Connect-MgGraph @connect # 1. Devices that aren't compliant. Unknown and not-evaluated devices are left out: they haven't reported yet. $states = @('noncompliant', 'inGracePeriod', 'error', 'conflict') $filter = ($states | ForEach-Object { "complianceState eq '$_'" }) -join ' or ' $select = 'id,deviceName,userPrincipalName,emailAddress,operatingSystem,osVersion,managedDeviceOwnerType,lastSyncDateTime,complianceState,complianceGracePeriodExpirationDateTime' $devices = Get-GraphList ("https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter={0}&`$select={1}" -f [uri]::EscapeDataString($filter), $select) Write-Log ("Read {0} devices that aren't compliant" -f $devices.Count) $rows = New-Object System.Collections.Generic.List[object] foreach ($d in $devices) { $policies = @(); $settings = @() if ($CheckSettings) { try { # Per-device policy and setting states are only in the beta endpoint. $policyStates = Get-GraphList "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates" foreach ($p in $policyStates) { if (@('compliant', 'notApplicable', 'unknown') -contains [string]$p.state) { continue } $policies += [string]$p.displayName foreach ($s in (Get-GraphList "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates/$($p.id)/settingStates")) { if (@('compliant', 'notApplicable', 'remediated') -contains [string]$s.state) { continue } $name = Get-SettingName ([string]$s.settingName) if ([string]$s.state -ne 'nonCompliant') { $name += " ($($s.state))" } if ($settings -notcontains $name) { $settings += $name } } } } catch { Write-Log "Setting lookup failed for $($d.deviceName): $($_.Exception.Message)" } } $sync = ConvertTo-UtcDate $d.lastSyncDateTime $grace = ConvertTo-UtcDate $d.complianceGracePeriodExpirationDateTime $syncDays = $null; if ($sync) { $syncDays = [int][math]::Floor(($now - $sync).TotalDays) } $state = [string]$d.complianceState $hoursLeft = $null $notes = @() switch ($state) { 'inGracePeriod' { $status = 'In grace period' if ($grace) { $hoursLeft = [math]::Max(0, [int][math]::Floor(($grace - $now).TotalHours)) } $next = 'Fix it before the grace period ends. After that, Conditional Access treats it as non-compliant.' } 'noncompliant' { $status = 'Noncompliant' $next = 'Fix the failing setting, then sync the device. Conditional Access is blocking it if you require compliant devices.' } default { $status = if ($state -eq 'error') { 'Error' } else { 'Conflict' } $next = 'Open the device in Intune > Device compliance. Error and conflict usually mean two policies disagree or a setting failed to evaluate.' } } if ($settings -contains $SettingNames['RequireRemainContact'] -or ($null -ne $syncDays -and $syncDays -ge 30)) { $notes += "Last check-in $syncDays days ago. If it's still in use, fix the enrolment; if it's gone, clean it up." } if (-not $d.emailAddress) { $notes += "Intune has no email address for this user, so 'Send email to end user' can't reach them." } $rows.Add([pscustomobject]@{ Status = $status DeviceName = $d.deviceName User = $d.userPrincipalName OS = $d.operatingSystem OSVersion = $d.osVersion Ownership = $d.managedDeviceOwnerType FailingSettings = ($settings -join '; ') Policies = ($policies -join '; ') GraceEnds = Format-Date $grace HoursLeft = $hoursLeft LastSync = Format-Date $sync DaysSinceSync = $syncDays NextStep = $next Note = ($notes -join ' ') }) } # 2. What each policy does when a device fails, and who hears about it $policySummary = @() if ($ShowPolicies) { try { $templates = @{} foreach ($t in (Get-GraphList 'https://graph.microsoft.com/beta/deviceManagement/notificationMessageTemplates?$select=id,displayName')) { $templates[[string]$t.id] = $t.displayName } $all = Get-GraphList 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$expand=assignments,scheduledActionsForRule($expand=scheduledActionConfigurations)' foreach ($p in $all) { $actions = @($p.scheduledActionsForRule | ForEach-Object { $_.scheduledActionConfigurations }) $block = $actions | Where-Object actionType -eq 'block' | Select-Object -First 1 $emails = @($actions | Where-Object actionType -eq 'notification') $copied = @($emails | Where-Object { @($_.notificationMessageCCList).Count -gt 0 }) $graceHours = if ($block) { [double]$block.gracePeriodHours } else { 0 } $policySummary += [pscustomobject]@{ Policy = $p.displayName Assigned = (@($p.assignments).Count -gt 0) MarkedAfter = if ($graceHours -eq 0) { 'Immediately' } elseif ($graceHours % 24 -eq 0) { "$($graceHours / 24) days" } else { "$graceHours hours" } EmailsUser = if ($emails.Count) { ($emails | ForEach-Object { 'day ' + [math]::Round($_.gracePeriodHours / 24, 2) }) -join ', ' } else { 'No' } CopiesIT = if ($copied.Count) { 'Yes' } else { 'No' } OtherActions = (@($actions | Where-Object { $_.actionType -notin @('block', 'notification') } | ForEach-Object { $_.actionType }) -join ', ') } } } catch { Write-Log "Policy read failed: $($_.Exception.Message)" Write-Host "Could not read compliance policies: $($_.Exception.Message)" -ForegroundColor Yellow } } # Output: grace period first, soonest deadline at the top, then non-compliant, then errors. $order = @{ 'In grace period' = 0; 'Noncompliant' = 1; 'Error' = 2; 'Conflict' = 3 } $sorted = @($rows | Sort-Object @{ Expression = { $order[$_.Status] } }, @{ Expression = { if ($null -eq $_.HoursLeft) { [int]::MaxValue } else { $_.HoursLeft } } }, DeviceName) if ($sorted.Count -gt 0) { $outDir = Split-Path -Path $OutputPath -Parent if ($outDir -and -not (Test-Path $outDir)) { New-Item -ItemType Directory -Path $outDir -Force | Out-Null } $sorted | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8 $sorted | Format-Table Status, DeviceName, User, FailingSettings, GraceEnds, LastSync -AutoSize | Out-String -Width 250 | Write-Host } if ($policySummary.Count) { Write-Host 'What each compliance policy does when a device fails:' $policySummary | Format-Table Policy, Assigned, MarkedAfter, EmailsUser, CopiesIT, OtherActions -AutoSize | Out-String -Width 250 | Write-Host if (-not @($policySummary | Where-Object CopiesIT -eq 'Yes').Count) { Write-Host 'No policy copies anyone in IT on its emails. Users hear about non-compliance first, or nobody does.' -ForegroundColor Yellow } } $counts = $sorted | Group-Object Status | ForEach-Object { "{0} {1}" -f $_.Count, $_.Name.ToLower() } if ($sorted.Count -gt 0) { Write-Host ("Found: {0}. CSV: {1}" -f ($counts -join ', '), $OutputPath) } else { Write-Host 'Every device that has reported is compliant.' } Write-Host 'Get this list every morning without running anything: https://atliso.com/pro?ref=noncompliant-device-audit' -ForegroundColor Cyan Write-Log ("Run finished: {0} flagged" -f $sorted.Count) if ($sorted.Count -gt 0) { exit 1 } else { exit 0 } |