Private/Get-IntuneAccessDeviceIntelligence.ps1

function ConvertTo-IntuneAccessDateTimeOffset {
    [CmdletBinding()]
    [OutputType([DateTimeOffset])]
    param([AllowNull()] [object] $Value)

    if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string] $Value)) { return $null }
    $parsed = [DateTimeOffset]::MinValue
    if ([DateTimeOffset]::TryParse([string] $Value, [ref] $parsed)) { return $parsed }
    return $null
}

function New-IntuneAccessDeviceFinding {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [object] $Device,
        [Parameter(Mandatory)] [string] $RuleId,
        [Parameter(Mandatory)] [ValidateSet('Critical', 'High', 'Medium', 'Low', 'Information')] [string] $Severity,
        [Parameter(Mandatory)] [string] $Category,
        [Parameter(Mandatory)] [string] $Title,
        [Parameter(Mandatory)] [string] $Explanation,
        [Parameter(Mandatory)] [string] $Recommendation,
        [AllowNull()] [object] $ObservedAt,
        [hashtable] $Evidence = @{}
    )

    [PSCustomObject] @{
        PSTypeName           = 'IntuneAccess.DeviceFinding'
        FindingId            = "$RuleId/$([string] $Device.Id)"
        RuleId               = $RuleId
        Severity             = $Severity
        Category             = $Category
        Title                = $Title
        DeviceId             = [string] $Device.Id
        DeviceName           = [string] $Device.DeviceName
        SerialNumber         = [string] $Device.SerialNumber
        EntraDeviceId        = [string] $Device.EntraDeviceId
        Explanation          = $Explanation
        ReviewRecommendation = $Recommendation
        Evidence             = [PSCustomObject] $Evidence
        EvidenceTimestamp    = $ObservedAt
        EvidenceState        = 'Observed'
        ReadOnly             = $true
    }
}

function Get-IntuneAccessDeviceIntelligence {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $ManagedDevice,
        [ValidateRange(1, 3650)] [int] $StaleAfterDays = 30,
        [ValidateRange(1, 3650)] [int] $NewEnrollmentGraceDays = 7,
        [datetimeoffset] $AsOf = [DateTimeOffset]::Now
    )

    $null = Assert-IntuneAccessConnection -RequiredScope @('DeviceManagementManagedDevices.Read.All', 'Device.Read.All')
    $warnings = [System.Collections.Generic.List[string]]::new()
    $entraDevices = @()
    try {
        $entraDevices = @(Invoke-IntuneAccessGraphRequest -Uri 'devices?$select=id,deviceId,displayName,accountEnabled,operatingSystem,operatingSystemVersion,trustType,approximateLastSignInDateTime,registrationDateTime' -ApiVersion v1.0)
    }
    catch {
        $warnings.Add("Microsoft Entra device records could not be collected. $($_.Exception.Message)")
    }

    $entraByDeviceId = @{}
    foreach ($entra in $entraDevices) {
        $deviceId = [string] (Get-IntuneAccessProperty $entra 'deviceId')
        if (-not [string]::IsNullOrWhiteSpace($deviceId)) {
            if (-not $entraByDeviceId.ContainsKey($deviceId.ToLowerInvariant())) { $entraByDeviceId[$deviceId.ToLowerInvariant()] = [System.Collections.Generic.List[object]]::new() }
            $entraByDeviceId[$deviceId.ToLowerInvariant()].Add($entra)
        }
    }

    $inventory = [System.Collections.Generic.List[object]]::new()
    foreach ($device in $ManagedDevice) {
        $entraMatches = @()
        $entraDeviceId = [string] (Get-IntuneAccessProperty $device 'EntraDeviceId')
        if (-not [string]::IsNullOrWhiteSpace($entraDeviceId) -and $entraByDeviceId.ContainsKey($entraDeviceId.ToLowerInvariant())) {
            $entraMatches = @($entraByDeviceId[$entraDeviceId.ToLowerInvariant()])
        }
        $entra = if ($entraMatches.Count -eq 1) { $entraMatches[0] } else { $null }
        $lastSync = ConvertTo-IntuneAccessDateTimeOffset (Get-IntuneAccessProperty $device 'LastSyncDateTime')
        $enrolled = ConvertTo-IntuneAccessDateTimeOffset (Get-IntuneAccessProperty $device 'EnrolledDateTime')
        $evidenceAge = if ($null -eq $lastSync) { $null } else { [math]::Max(0, [math]::Floor(($AsOf - $lastSync).TotalDays)) }
        $inventory.Add([PSCustomObject] @{
            PSTypeName                    = 'IntuneAccess.DeviceInventoryRecord'
            Id                            = [string] (Get-IntuneAccessProperty $device 'Id')
            DeviceName                    = [string] (Get-IntuneAccessProperty $device 'DeviceName')
            SerialNumber                  = [string] (Get-IntuneAccessProperty $device 'SerialNumber')
            EntraDeviceId                 = $entraDeviceId
            UserId                        = [string] (Get-IntuneAccessProperty $device 'UserId')
            UserPrincipalName             = [string] (Get-IntuneAccessProperty $device 'UserPrincipalName')
            PrimaryUserState              = if ([string]::IsNullOrWhiteSpace([string] (Get-IntuneAccessProperty $device 'UserId'))) { 'Missing' } else { 'Observed' }
            Manufacturer                  = [string] (Get-IntuneAccessProperty $device 'Manufacturer')
            Model                         = [string] (Get-IntuneAccessProperty $device 'Model')
            OperatingSystem               = [string] (Get-IntuneAccessProperty $device 'OperatingSystem')
            OsVersion                     = [string] (Get-IntuneAccessProperty $device 'OsVersion')
            Ownership                     = [string] (Get-IntuneAccessProperty $device 'Ownership')
            ManagementAgent               = [string] (Get-IntuneAccessProperty $device 'ManagementAgent')
            EnrollmentType                = [string] (Get-IntuneAccessProperty $device 'EnrollmentType')
            ComplianceState               = [string] (Get-IntuneAccessProperty $device 'ComplianceState')
            ManagementState               = [string] (Get-IntuneAccessProperty $device 'ManagementState')
            EnrolledDateTime              = $enrolled
            LastSyncDateTime              = $lastSync
            EvidenceAgeDays               = $evidenceAge
            EntraCorrelationState         = if ([string]::IsNullOrWhiteSpace($entraDeviceId)) { 'MissingIdentifier' } elseif ($entraMatches.Count -eq 0) { 'NotFound' } elseif ($entraMatches.Count -gt 1) { 'Ambiguous' } else { 'Matched' }
            EntraObjectId                 = if ($null -eq $entra) { '' } else { [string] (Get-IntuneAccessProperty $entra 'id') }
            EntraDisplayName              = if ($null -eq $entra) { '' } else { [string] (Get-IntuneAccessProperty $entra 'displayName') }
            EntraAccountEnabled           = if ($null -eq $entra) { $null } else { Get-IntuneAccessProperty $entra 'accountEnabled' }
            EntraOperatingSystem          = if ($null -eq $entra) { '' } else { [string] (Get-IntuneAccessProperty $entra 'operatingSystem') }
            EntraOperatingSystemVersion   = if ($null -eq $entra) { '' } else { [string] (Get-IntuneAccessProperty $entra 'operatingSystemVersion') }
            EntraTrustType                = if ($null -eq $entra) { '' } else { [string] (Get-IntuneAccessProperty $entra 'trustType') }
            EntraApproximateLastSignIn    = if ($null -eq $entra) { $null } else { ConvertTo-IntuneAccessDateTimeOffset (Get-IntuneAccessProperty $entra 'approximateLastSignInDateTime') }
            SourceApiVersion              = 'v1.0'
            GeneratedAt                   = $AsOf
        })
    }

    $findings = [System.Collections.Generic.List[object]]::new()
    $serialGroups = @($inventory | Where-Object { -not [string]::IsNullOrWhiteSpace($_.SerialNumber) } | Group-Object { $_.SerialNumber.Trim().ToLowerInvariant() } | Where-Object Count -GT 1)
    $entraGroups = @($inventory | Where-Object { -not [string]::IsNullOrWhiteSpace($_.EntraDeviceId) } | Group-Object { $_.EntraDeviceId.Trim().ToLowerInvariant() } | Where-Object Count -GT 1)
    $duplicateSerialIds = @($serialGroups | ForEach-Object { @($_.Group) | ForEach-Object { [string] $_.Id } })
    $duplicateEntraIds = @($entraGroups | ForEach-Object { @($_.Group) | ForEach-Object { [string] $_.Id } })

    foreach ($device in $inventory) {
        if ($null -eq $device.LastSyncDateTime) {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-CHECKIN-MISSING' -Severity Medium -Category Hygiene -Title 'No confirmed Intune check-in timestamp' -Explanation 'The managed-device record did not return a last sync timestamp.' -Recommendation 'Review the device record and client state before deciding whether it is active.' -ObservedAt $AsOf -Evidence @{ LastSyncDateTime = $null }))
        }
        elseif ($device.EvidenceAgeDays -ge $StaleAfterDays) {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-CHECKIN-STALE' -Severity High -Category Hygiene -Title 'Stale Intune check-in' -Explanation "The last confirmed Intune check-in is $($device.EvidenceAgeDays) days old." -Recommendation 'Confirm whether the device is still in service and investigate client connectivity before considering lifecycle action.' -ObservedAt $device.LastSyncDateTime -Evidence @{ EvidenceAgeDays = $device.EvidenceAgeDays; ThresholdDays = $StaleAfterDays }))
        }
        if ($null -ne $device.EnrolledDateTime -and ($AsOf - $device.EnrolledDateTime).TotalDays -ge $NewEnrollmentGraceDays -and ($null -eq $device.LastSyncDateTime -or $device.LastSyncDateTime -lt $device.EnrolledDateTime)) {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-ENROLMENT-NO-HEALTHY-CHECKIN' -Severity High -Category Enrolment -Title 'Enrolment has no later healthy check-in evidence' -Explanation 'The enrolment timestamp is older than the grace period and no later Intune check-in was observed.' -Recommendation 'Review the enrolment record, device connectivity and management client state.' -ObservedAt $device.EnrolledDateTime -Evidence @{ GraceDays = $NewEnrollmentGraceDays; EnrolledDateTime = $device.EnrolledDateTime; LastSyncDateTime = $device.LastSyncDateTime }))
        }
        if ([string]::IsNullOrWhiteSpace($device.UserId)) {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-PRIMARY-USER-MISSING' -Severity Low -Category Identity -Title 'No primary user evidence' -Explanation 'The managed-device record did not return an associated Microsoft Entra user.' -Recommendation 'Confirm whether this is expected for a shared, kiosk or userless device.' -ObservedAt $device.LastSyncDateTime -Evidence @{ EnrollmentType = $device.EnrollmentType }))
        }
        if ($device.EntraCorrelationState -ne 'Matched') {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-ENTRA-CORRELATION' -Severity Medium -Category Identity -Title 'Intune and Microsoft Entra device identity did not reconcile exactly' -Explanation "The correlation state is $($device.EntraCorrelationState)." -Recommendation 'Review the raw Intune and Microsoft Entra identifiers before removing or merging any record.' -ObservedAt $device.LastSyncDateTime -Evidence @{ CorrelationState = $device.EntraCorrelationState; EntraDeviceId = $device.EntraDeviceId }))
        }
        if ($device.Id -in $duplicateSerialIds) {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-DUPLICATE-SERIAL' -Severity High -Category Duplicate -Title 'Duplicate serial number' -Explanation 'More than one Intune managed-device record returned the same non-empty serial number.' -Recommendation 'Compare enrolment and check-in timestamps before deciding which record, if any, is stale.' -ObservedAt $device.LastSyncDateTime -Evidence @{ SerialNumber = $device.SerialNumber; MatchingRecordCount = @($inventory | Where-Object SerialNumber -EQ $device.SerialNumber).Count }))
        }
        if ($device.Id -in $duplicateEntraIds) {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-DUPLICATE-ENTRA-ID' -Severity High -Category Duplicate -Title 'Duplicate Microsoft Entra device identifier' -Explanation 'More than one Intune managed-device record returned the same Microsoft Entra device identifier.' -Recommendation 'Review the related Intune records and enrolment history before taking lifecycle action.' -ObservedAt $device.LastSyncDateTime -Evidence @{ EntraDeviceId = $device.EntraDeviceId; MatchingRecordCount = @($inventory | Where-Object EntraDeviceId -EQ $device.EntraDeviceId).Count }))
        }
        if ($device.ComplianceState -match '^(unknown|notEvaluated|)$') {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-COMPLIANCE-UNKNOWN' -Severity Medium -Category Compliance -Title 'No confirmed compliance result' -Explanation "The current managed-device compliance state is '$($device.ComplianceState)'." -Recommendation 'Review compliance targeting, policy status and evidence age before treating the device as compliant or non-compliant.' -ObservedAt $device.LastSyncDateTime -Evidence @{ ComplianceState = $device.ComplianceState }))
        }
        if (-not [string]::IsNullOrWhiteSpace($device.EntraOperatingSystemVersion) -and -not [string]::IsNullOrWhiteSpace($device.OsVersion) -and $device.EntraOperatingSystemVersion -ne $device.OsVersion) {
            $findings.Add((New-IntuneAccessDeviceFinding -Device $device -RuleId 'DEV-OS-VERSION-MISMATCH' -Severity Low -Category Mismatch -Title 'Intune and Microsoft Entra OS versions differ' -Explanation 'The two services returned different operating system versions for the correlated device.' -Recommendation 'Compare evidence timestamps; service reporting intervals can differ.' -ObservedAt $device.LastSyncDateTime -Evidence @{ IntuneOsVersion = $device.OsVersion; EntraOsVersion = $device.EntraOperatingSystemVersion }))
        }
    }

    [PSCustomObject] @{
        PSTypeName           = 'IntuneAccess.DeviceIntelligence'
        Inventory            = $inventory.ToArray()
        Findings             = @($findings | Sort-Object @{ Expression = { @('Critical','High','Medium','Low','Information').IndexOf($_.Severity) } }, DeviceName, RuleId)
        EntraDevices         = $entraDevices
        CollectionStatus     = [PSCustomObject] @{ State = if ($warnings.Count -eq 0) { 'Available' } else { 'Partial' }; IntuneRecordCount = $ManagedDevice.Count; EntraRecordCount = $entraDevices.Count; FindingCount = $findings.Count; ApiVersion = 'v1.0' }
        Warnings             = $warnings.ToArray()
        GraphPermissionsUsed = @('DeviceManagementManagedDevices.Read.All', 'Device.Read.All')
        GeneratedAt          = $AsOf
        ToolVersion          = $script:IntuneAccessVersion
    }
}