public/Get-MsecIntuneDeviceCompliance.ps1
|
function Get-MsecIntuneDeviceCompliance { <# .SYNOPSIS Why each device is non-compliant - the policy and the individual setting that failed, separating devices that break a security control from devices that merely went quiet. .DESCRIPTION Get-MsecIntuneDevice reports THAT a device is non-compliant. The reason lives two levels below it and nothing surfaces it: the failing policy is on /managedDevices/{id}/deviceCompliancePolicyStates and the failing SETTING is a further call per policy. This command makes that join, one row per device per failing setting. MOST NON-COMPLIANCE IS NOT A SECURITY FAILURE, AND TREATING IT AS ONE IS THE MISTAKE THIS COMMAND EXISTS TO PREVENT. Intune ships a built-in 'Default Device Compliance Policy' whose rules are housekeeping - has the device checked in recently (RequireRemainContact), does the owning account still exist (RequireUserExistence), is any policy assigned at all (RequireDeviceCompliancePolicyAssigned). A device failing those is stale, not insecure. Measured on one tenant: 33 non-compliant devices, 30 attributable failures, and every single one from the default policy - not one device failed encryption, OS version or any configured baseline. Category marks each row 'Housekeeping' or 'SecurityControl' so a report can say which it found. A FAILED READ AND AN EMPTY ONE ARE DIFFERENT, AND BOTH ARE REPORTED. Intune throttles this endpoint hard - two calls per device - and Invoke-MsecGraphRequest gives up after five retries, so on a tenant of any size some devices lose their read to a 429. Category 'Unreadable' marks those; 'Unattributed' means the call SUCCEEDED and returned nothing. Collapsing the two was the original behaviour and it made the output differ run to run: the same tenant gave 4 unattributed devices on one pass and 9 on the next, and one of them had three perfectly readable settings when queried alone. The run also warns when anything was unreadable, because a silently short report is worse than a slow one. A DEVICE WHOSE SETTINGS CANNOT BE ATTRIBUTED IS REPORTED, NOT DROPPED. A device can be flagged non-compliant while its settingStates return nothing - measured, 4 of 33 - and omitting those rows would quietly shrink the problem. They come back with Category 'Unattributed' and a null Setting, because "we could not determine why" and "there is nothing wrong" must not look the same. READ settingName, NOT setting. The 'setting' property is present and EMPTY on every row the v1.0 endpoint returns; the human-readable rule is in 'settingName'. Reading the obvious one yields a column of blanks that looks like missing data. THIS COSTS TWO GRAPH CALLS PER DEVICE, which is why it defaults to non-compliant devices only. A tenant of any size should not pay for the compliant ones to answer a question about the broken ones; -IncludeCompliant lifts that when the full picture is actually wanted. Runs as the msec app. Needs 'DeviceManagementManagedDevices.Read.All'. .PARAMETER IncludeCompliant Also evaluate devices that are currently compliant. Two extra Graph calls per device. .PARAMETER DeviceName Only devices whose name matches this wildcard pattern, e.g. 'VIE01*'. .PARAMETER SecurityOnly Only failures of a real configured policy - drops the built-in default policy's housekeeping rules, which is usually what someone means by "non-compliant devices". ROWS WHOSE REASON COULD NOT BE READ ARE STILL RETURNED, because an unreadable reason might be a security failure and this is the view someone uses to find those. Filter them out explicitly with Where-Object Category -eq 'SecurityControl' if you want only what is confirmed. .EXAMPLE Get-MsecIntuneDeviceCompliance .EXAMPLE # The failures that actually indicate an insecure device. Get-MsecIntuneDeviceCompliance -SecurityOnly .EXAMPLE # What is actually wrong, tenant-wide. Get-MsecIntuneDeviceCompliance | Group-Object Setting | Sort-Object Count -Descending .OUTPUTS PSCustomObject per device per failing setting, PSTypeName 'MsecIntuneDeviceCompliance'. .NOTES Read-only. A device that has not checked in cannot be remediated by Intune at all - policy only applies when the device talks to the service - so DaysQuiet is the column that decides whether a finding is actionable or whether the device simply needs retiring. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [switch] $IncludeCompliant, [string] $DeviceName, [switch] $SecurityOnly ) Assert-MsecSession $devices = @(Get-MsecIntuneDevice) if ($DeviceName) { $devices = @($devices | Where-Object { $_.DeviceName -like $DeviceName }) } if (-not $IncludeCompliant) { $devices = @($devices | Where-Object { $_.ComplianceState -notin @('compliant', 'unknown') }) } $now = [datetime]::UtcNow $i = 0 # Counted so the run can say how much of its own output it could not actually read. $script:MsecComplianceUnreadable = 0 foreach ($d in $devices) { $i++ Write-Progress -Id 1820 -Activity 'Reading device compliance' ` -Status "$i of $($devices.Count) - $($d.DeviceName)" ` -PercentComplete (100 * ($i - 1) / [Math]::Max(1, $devices.Count)) $daysQuiet = if ($d.LastSyncDateTime) { [int]($now - $d.LastSyncDateTime.ToUniversalTime()).TotalDays } else { $null } $states = @() # A FAILED READ IS NOT AN EMPTY RESULT, and on this endpoint the difference is # routine rather than theoretical: two calls per device makes Intune's compliance # endpoints throttle, and Invoke-MsecGraphRequest gives up after 5 retries. Treating # the exhausted call as "no settings" turns a transient 429 into a device that looks # like it has nothing wrong with it - and makes the output differ run to run, which # is how it was first noticed. $readFailed = $false try { $states = @(Invoke-MsecGraphRequest -All ` -Path "/v1.0/deviceManagement/managedDevices/$($d.Id)/deviceCompliancePolicyStates") } catch { if ($_.Exception.Message -match '403|Forbidden') { throw ("Forbidden reading deviceCompliancePolicyStates. The msec app needs " + "'DeviceManagementManagedDevices.Read.All' (admin consent required). " + "Original error: $($_.Exception.Message)") } $readFailed = $true $script:MsecComplianceUnreadable++ Write-Verbose "Could not read policy states for $($d.DeviceName): $($_.Exception.Message)" } $failing = @($states | Where-Object { $_.state -eq 'nonCompliant' }) # A non-compliant device with nothing to attribute it to still has to appear. Dropping # it would make the problem look smaller than it is. if (-not $failing -and $d.ComplianceState -eq 'noncompliant') { [PSCustomObject]@{ PSTypeName = 'MsecIntuneDeviceCompliance' DeviceName = $d.DeviceName Os = $d.Os Owner = $d.UserPrincipalName PolicyName = $null Setting = $null # Not 'nothing wrong' - 'could not determine what'. # 'Unreadable' when the call FAILED, 'Unattributed' when it succeeded and # returned nothing. A throttled read must not look like a clean device. Category = if ($readFailed) { 'Unreadable' } else { 'Unattributed' } LastSyncUtc = $d.LastSyncDateTime DaysQuiet = $daysQuiet DeviceId = $d.Id } continue } foreach ($pol in $failing) { $settings = @() $settingReadFailed = $false try { $settings = @(Invoke-MsecGraphRequest -All ` -Path "/v1.0/deviceManagement/managedDevices/$($d.Id)/deviceCompliancePolicyStates/$($pol.id)/settingStates") } catch { $settingReadFailed = $true $script:MsecComplianceUnreadable++ Write-Verbose "Could not read setting states for $($d.DeviceName)/$($pol.displayName)" } $bad = @($settings | Where-Object { $_.state -eq 'nonCompliant' }) # The policy failed but no setting within it did - same ambiguity as above, one # level down, and reported the same way rather than silently skipped. if (-not $bad) { $row = [PSCustomObject]@{ PSTypeName = 'MsecIntuneDeviceCompliance' DeviceName = $d.DeviceName Os = $d.Os Owner = $d.UserPrincipalName PolicyName = [string]$pol.displayName Setting = $null # Same distinction one level down: the policy failed but the settings # could not be read, which is not the same as there being none. Category = if ($settingReadFailed) { 'Unreadable' } else { 'Unattributed' } LastSyncUtc = $d.LastSyncDateTime DaysQuiet = $daysQuiet DeviceId = $d.Id } # NOT filtered by -SecurityOnly: an unreadable reason might BE a security # failure, and the view someone uses to find those is the worst place for it # to vanish from. $row continue } foreach ($s in $bad) { # The built-in default policy's rules are housekeeping: checked in recently, # account still exists, any policy assigned. Failing one means stale, not # insecure, and the distinction is the whole point of the command. $name = [string]$s.settingName $isDefault = $name -like 'DefaultDeviceCompliancePolicy.*' $category = if ($isDefault) { 'Housekeeping' } else { 'SecurityControl' } if ($SecurityOnly -and $category -ne 'SecurityControl') { continue } [PSCustomObject]@{ PSTypeName = 'MsecIntuneDeviceCompliance' DeviceName = $d.DeviceName Os = $d.Os Owner = $d.UserPrincipalName PolicyName = [string]$pol.displayName # settingName, not setting - 'setting' is empty on every v1.0 row. Setting = $name Category = $category LastSyncUtc = $d.LastSyncDateTime # A device that has not checked in cannot be remediated by Intune at all. DaysQuiet = $daysQuiet DeviceId = $d.Id } } } } Write-Progress -Id 1820 -Activity 'Reading device compliance' -Completed # SAID OUT LOUD, because the alternative is a run that silently under-reports. Intune's # compliance endpoints throttle under two calls per device and the Graph helper gives up # after 5 retries, so a large tenant can lose devices to 429s - and the loss is invisible # in the output except as rows that would otherwise look like nothing is wrong. if ($script:MsecComplianceUnreadable -gt 0) { Write-Warning ("$($script:MsecComplianceUnreadable) compliance read(s) failed and are " + "reported as Category 'Unreadable' rather than as clean devices. The usual cause " + 'is Microsoft Graph throttling: this command makes two calls per device and ' + 'Intune throttles that endpoint hard. Re-run, or narrow with -DeviceName, to ' + 'attribute them.') } } |