Checks/Test-HygieneDeviceCompliance.ps1

<#
.SYNOPSIS
    Finds Intune-managed devices that are not meeting their compliance policies.

.DESCRIPTION
    Conditional Access policies that require a compliant device are only as good as
    the compliance state underneath them. This check reports the devices that would
    fail such a policy, and -- more usefully -- the ones that would quietly pass it
    while nothing has actually been verified.

    The states are treated separately because they mean different things:

      noncompliant a policy was evaluated and the device failed it.
      inGracePeriod failing, but inside the window before enforcement bites. This is
                    a deadline, not a pass.
      unknown the device has not checked in recently enough to say.
      notApplicable no compliance policy targets the device at all. This is the one
      / no policy worth caring about: the device reports no failure because nothing
                    ever asked it a question, and on a dashboard it looks fine.

    Devices that have not contacted Intune for a long time are flagged separately,
    since a stale compliant verdict describes a device as it was months ago.

.PARAMETER Context
    Shared audit context from New-HygieneAuditContext.

.OUTPUTS
    M365TenantHygiene.Finding objects.

.NOTES
    Graph endpoint : GET /deviceManagement/managedDevices
    Scope required : DeviceManagementManagedDevices.Read.All (delegated)

    Requires an active Intune licence on the tenant. Without one the endpoint is not
    available, and the check reports Skipped rather than reporting zero non-compliant
    devices, which would read as a clean estate.
#>

function Test-HygieneDeviceCompliance {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][hashtable] $Context)

    $meta          = (Get-HygieneCheckRegistry)['DeviceCompliance']
    $staleDayLimit = 30

    try {
        $devices = Invoke-HygieneGraphRequest `
            -Uri 'deviceManagement/managedDevices?$select=id,deviceName,userPrincipalName,operatingSystem,osVersion,complianceState,managementAgent,lastSyncDateTime,enrolledDateTime,isEncrypted,jailBroken,manufacturer,model' `
            -MaxPages $Context.MaxPages
    }
    catch {
        $kind = $_.Exception.Data['Kind']
        if ($kind -in @('Permission', 'NotFound')) {
            return New-HygieneCheckResult `
                -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
                -Status 'Skipped' `
                -RequiredScopes $meta.RequiredScopes `
                -Reason ('Microsoft Intune device management is not reachable for this tenant ({0}). This usually means no active Intune licence. No conclusion about device compliance can be drawn from this run.' -f $_.Exception.Message)
        }
        throw
    }

    Write-HygieneLog -Message "Read $($devices.Count) managed device(s)."

    $findings = [System.Collections.Generic.List[object]]::new()

    foreach ($device in $devices) {

        $state = $device.complianceState
        $name  = if ($device.deviceName) { $device.deviceName } else { $device.id }

        $lastSync = $null
        if ($device.lastSyncDateTime) {
            $parsed = [datetime]::MinValue
            if ([datetime]::TryParse($device.lastSyncDateTime, [ref] $parsed)) { $lastSync = $parsed.ToUniversalTime() }
        }
        $daysSinceSync = if ($lastSync) { [int] ([datetime]::UtcNow - $lastSync).TotalDays } else { $null }

        $evidence = @{
            deviceId        = $device.id
            operatingSystem = $device.operatingSystem
            osVersion       = $device.osVersion
            complianceState = $state
            managementAgent = $device.managementAgent
            lastSyncUtc     = if ($lastSync) { $lastSync.ToString('o') } else { $null }
            daysSinceSync   = $daysSinceSync
            enrolledDateTime = $device.enrolledDateTime
            isEncrypted     = $device.isEncrypted
            jailBroken      = $device.jailBroken
            manufacturer    = $device.manufacturer
            model           = $device.model
            assignedUser    = $device.userPrincipalName
        }

        $common = @{
            CheckId    = $meta.Id
            CheckName  = $meta.Name
            Category   = $meta.Category
            ObjectType = 'Device'
            ObjectId   = $device.id
            ObjectName = $name
            Evidence   = $evidence
            Reference  = 'https://learn.microsoft.com/mem/intune/protect/device-compliance-get-started'
        }

        switch ($state) {

            'noncompliant' {
                $findings.Add((New-HygieneFinding @common `
                    -Severity 'High' `
                    -Title 'Device is non-compliant' `
                    -Detail "The device failed the compliance policies targeting it. If a Conditional Access policy requires a compliant device, this device is being blocked; if no such policy exists, it is reaching corporate data while failing your own baseline." `
                    -Recommendation 'Open the device in Intune to see which settings failed, and fix the setting rather than the compliance policy.'))
            }

            'inGracePeriod' {
                $findings.Add((New-HygieneFinding @common `
                    -Severity 'Medium' `
                    -Title 'Device is non-compliant but still inside its grace period' `
                    -Detail 'The device is failing its compliance policies and is inside the grace window before enforcement applies. It reports as tolerated today and will be blocked when the window closes.' `
                    -Recommendation 'Treat this as a deadline. Remediate before the grace period ends, rather than extending the grace period.'))
            }

            'unknown' {
                $findings.Add((New-HygieneFinding @common `
                    -Severity 'Medium' `
                    -Title 'Device compliance is unknown' `
                    -Detail "Intune has no current compliance verdict for this device$(if ($null -ne $daysSinceSync) { "; it last synchronised $daysSinceSync day(s) ago" }). An unknown state is not a pass: nothing about this device has been verified." `
                    -Recommendation 'Confirm the device is still in use and still enrolled. Retire the record if the device is gone, and investigate why it stopped checking in if it is not.'))
            }

            { $_ -in @('notApplicable', 'configManager') } {
                $findings.Add((New-HygieneFinding @common `
                    -Severity 'Medium' `
                    -Title 'No compliance policy applies to this device' `
                    -Detail "The device reports '$state', meaning no Intune compliance policy targets it. It will never be reported as non-compliant, because nothing is evaluating it -- and a Conditional Access policy requiring compliance may still let it through." `
                    -Recommendation 'Extend a compliance policy to cover this device''s platform and group. Gaps in policy targeting are the most common reason a fleet looks compliant.'))
            }

            'compliant' {
                if ($null -ne $daysSinceSync -and $daysSinceSync -gt $staleDayLimit) {
                    $findings.Add((New-HygieneFinding @common `
                        -Severity 'Low' `
                        -Title 'Compliant verdict is stale' `
                        -Detail "The device is recorded as compliant but last synchronised $daysSinceSync day(s) ago, beyond the $staleDayLimit day threshold. The verdict describes the device as it was then, not as it is now." `
                        -Recommendation 'Confirm the device is still in service. Retire records for devices that are gone so the compliance figures describe the real estate.'))
                }
            }

            default {
                $findings.Add((New-HygieneFinding @common `
                    -Severity 'Low' `
                    -Title "Unrecognised compliance state '$state'" `
                    -Detail "Intune reported a compliance state this module does not classify. It is surfaced rather than dropped so it is not silently treated as compliant." `
                    -Recommendation 'Review the device in Intune directly.'))
            }
        }
    }

    New-HygieneCheckResult `
        -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
        -Status 'Completed' `
        -Findings @($findings) `
        -ObjectsEvaluated $devices.Count `
        -RequiredScopes $meta.RequiredScopes
}