Atliso-StaleDeviceAudit.ps1

<#PSScriptInfo
.VERSION 1.0.0
.GUID 3289288f-42a9-48a1-873e-5fa7222496a0
.AUTHOR Atliso
.COMPANYNAME Atliso
.COPYRIGHT (c) 2026 Atliso. MIT License.
.TAGS Intune Entra Stale Devices Cleanup Inactive Graph Audit ReadOnly
.LICENSEURI https://github.com/Atliso-Dev/stale-device-audit/blob/main/LICENSE
.PROJECTURI https://atliso.com/blog/intune-stale-devices-cleanup
.REQUIREDMODULES Microsoft.Graph.Authentication
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES First release.
#>


<#
.SYNOPSIS
  Atliso Stale Device Audit. Read-only.
.DESCRIPTION
  Lists Intune devices that haven't checked in for $StaleDays days, cross-checked against the
  Entra ID activity timestamp, plus Entra-only devices that have gone quiet. Flags duplicate
  records left behind by a re-enrolment, devices with BitLocker keys stored in Entra, Autopilot
  devices and hybrid joined devices, so you know what is safe to delete before you delete it.
  Exports a CSV. Changes nothing in your tenant, so there is no WhatIf switch.
  https://atliso.com/blog/intune-stale-devices-cleanup
.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, Device.Read.All
          (+ BitlockerKey.ReadBasic.All when $CheckBitLockerKeys is on)
          (+ DeviceManagementServiceConfig.Read.All when $CheckAutopilot is on)
  Exit 0 = nothing stale. Exit 1 = stale, duplicate or broken devices found.
#>

[CmdletBinding()]
param(
  [string]$TenantId
)

# ===== CONFIG =====
$OutputPath         = Join-Path $env:USERPROFILE 'Desktop\Atliso-StaleDevices.csv'
$StaleDays          = 90     # no Intune check-in (or Entra sign-in) for this many days
$CheckBitLockerKeys = $true  # count recovery keys in Entra: deleting the device deletes them
$CheckAutopilot     = $true  # flag Autopilot devices: deleting their Entra object breaks reuse
$LogPath            = Join-Path $env:LOCALAPPDATA 'Atliso\StaleDeviceAudit.log'
# ==================

$ErrorActionPreference = 'Stop'

# Entra only rewrites the activity timestamp when it's 14 days (+/- 5) out of date, so anything
# under 21 days would flag devices that are in use.
if ($StaleDays -lt 21) {
  Write-Host "StaleDays is $StaleDays. Entra's activity timestamp can lag by up to 19 days, so using 21." -ForegroundColor Yellow
  $StaleDays = 21
}

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, [hashtable]$Headers = @{}) {
  for ($try = 1; $try -le 5; $try++) {
    try {
      return Invoke-MgGraphRequest -Method GET -Uri $Uri -Headers $Headers -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, [hashtable]$Headers = @{}) {
  $items = New-Object System.Collections.Generic.List[object]
  while ($Uri) {
    $page = Invoke-Graph $Uri $Headers
    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 for dates that were never set. Treat anything before 2000 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) { return $null }
  return $d
}

$now = (Get-Date).ToUniversalTime()
function Get-DaysAgo($Date) { if ($Date) { [int][math]::Floor(($now - $Date).TotalDays) } else { $null } }
function Format-Date($Date) { if ($Date) { $Date.ToString('yyyy-MM-dd') } else { '' } }

# Serials that firmware ships blank or as a placeholder can't identify a device.
function Test-RealSerial([string]$Serial) {
  if (-not $Serial) { return $false }
  return -not ($Serial.Trim() -match '^(0+|default string|to be filled by o\.e\.m\.|system serial number|none|n/a)$')
}

Write-Log 'Run started'
$scopes = @('DeviceManagementManagedDevices.Read.All', 'Device.Read.All')
if ($CheckBitLockerKeys) { $scopes += 'BitlockerKey.ReadBasic.All' }
if ($CheckAutopilot)     { $scopes += 'DeviceManagementServiceConfig.Read.All' }
$connect = @{ Scopes = $scopes; NoWelcome = $true }
if ($TenantId) { $connect.TenantId = $TenantId }
Connect-MgGraph @connect

# 1. Intune and Entra device records
$intune = Get-GraphList 'https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$select=id,deviceName,azureADDeviceId,lastSyncDateTime,enrolledDateTime,operatingSystem,osVersion,serialNumber,userPrincipalName,managedDeviceOwnerType,complianceState'
$entra  = Get-GraphList 'https://graph.microsoft.com/v1.0/devices?$select=id,deviceId,displayName,approximateLastSignInDateTime,accountEnabled,operatingSystem,operatingSystemVersion,trustType&$top=999'
Write-Log ("Read {0} Intune devices, {1} Entra devices" -f $intune.Count, $entra.Count)

$entraById = @{}
foreach ($e in $entra) { if ($e.deviceId) { $entraById[[string]$e.deviceId] = $e } }

# 2. BitLocker recovery keys per Entra device. Lists key IDs only, never the keys themselves.
$keyCount = @{}
$keysRead = $false
if ($CheckBitLockerKeys) {
  try {
    $keys = Get-GraphList 'https://graph.microsoft.com/v1.0/informationProtection/bitlocker/recoveryKeys?$select=id,deviceId' @{ 'ocp-client-name' = 'Atliso Stale Device Audit'; 'ocp-client-version' = '1.0.0' }
    foreach ($k in $keys) { if ($k.deviceId) { $keyCount[[string]$k.deviceId] = 1 + [int]$keyCount[[string]$k.deviceId] } }
    $keysRead = $true
  } catch {
    Write-Log "BitLocker key read failed: $($_.Exception.Message)"
    Write-Host "Could not read BitLocker recovery keys, so the BitLockerKeys column is blank. Your account needs a role such as Intune Administrator, Helpdesk Administrator or Security Reader." -ForegroundColor Yellow
  }
}

# 3. Autopilot registrations, keyed by Entra device ID
$autopilot = @{}
if ($CheckAutopilot) {
  try {
    foreach ($a in (Get-GraphList 'https://graph.microsoft.com/v1.0/deviceManagement/windowsAutopilotDeviceIdentities')) {
      if ($a.azureActiveDirectoryDeviceId) { $autopilot[[string]$a.azureActiveDirectoryDeviceId] = $true }
    }
  } catch {
    Write-Log "Autopilot read failed: $($_.Exception.Message)"
    Write-Host "Could not read Autopilot devices: $($_.Exception.Message)" -ForegroundColor Yellow
  }
}

# 4. Newest Intune enrolment per serial number, to spot records left behind by a re-enrolment
$newestBySerial = @{}
foreach ($d in $intune) {
  if (-not (Test-RealSerial $d.serialNumber)) { continue }
  $s = ([string]$d.serialNumber).Trim().ToUpper()
  $enrolled = ConvertTo-UtcDate $d.enrolledDateTime
  if (-not $newestBySerial.ContainsKey($s) -or ($enrolled -and $enrolled -gt $newestBySerial[$s].Enrolled)) {
    $newestBySerial[$s] = [pscustomobject]@{ Id = $d.id; Enrolled = $enrolled; Name = $d.deviceName }
  }
}

$rows = New-Object System.Collections.Generic.List[object]

function Add-Row {
  param($Status, $Name, $User, $Os, $OsVersion, $Ownership, $IntuneSync, $EntraSeen, $Serial, $EntraDevice, [string]$EntraDeviceId, $NextStep, [string[]]$Notes = @())
  $keys = ''
  $isAutopilot = $false
  $join = ''
  if ($EntraDeviceId) {
    if ($keysRead) { $keys = [int]$keyCount[$EntraDeviceId] }
    $isAutopilot = [bool]$autopilot[$EntraDeviceId]
  }
  if ($EntraDevice) {
    $join = switch ([string]$EntraDevice.trustType) { 'AzureAd' { 'Entra joined' } 'ServerAd' { 'Hybrid joined' } 'Workplace' { 'Entra registered' } default { [string]$EntraDevice.trustType } }
    if ($EntraDevice.accountEnabled -eq $false) { $Notes += 'Already disabled in Entra.' }
    if ([string]$EntraDevice.trustType -eq 'ServerAd') { $Notes += 'Hybrid joined: disable or delete it in on-prem AD and let Entra Connect sync the change.' }
  }
  if ($keys -is [int] -and $keys -gt 0) { $Notes += "$keys BitLocker recovery key(s) in Entra. Deleting the Entra device deletes them, so save them first." }
  if ($isAutopilot) { $Notes += "Registered in Autopilot. Deregister it in Intune first, and don't delete the Entra device if the hardware will be reused." }

  $rows.Add([pscustomobject]@{
    Status          = $Status
    DeviceName      = $Name
    User            = $User
    OS              = $Os
    OSVersion       = $OsVersion
    Ownership       = $Ownership
    IntuneLastSync  = Format-Date $IntuneSync
    IntuneDays      = Get-DaysAgo $IntuneSync
    EntraLastSeen   = Format-Date $EntraSeen
    EntraDays       = Get-DaysAgo $EntraSeen
    JoinType        = $join
    Serial          = $Serial
    BitLockerKeys   = $keys
    Autopilot       = $isAutopilot
    NextStep        = $NextStep
    Note            = ($Notes -join ' ')
  })
}

$inIntune = @{}
foreach ($d in $intune) {
  $entraId = [string]$d.azureADDeviceId
  if ($entraId -eq '00000000-0000-0000-0000-000000000000') { $entraId = '' }
  if ($entraId) { $inIntune[$entraId] = $true }
  $e = $null
  if ($entraId) { $e = $entraById[$entraId] }

  $sync = ConvertTo-UtcDate $d.lastSyncDateTime
  $seen = $null
  if ($e) { $seen = ConvertTo-UtcDate $e.approximateLastSignInDateTime }
  $syncDays = Get-DaysAgo $sync
  $seenDays = Get-DaysAgo $seen

  # A newer enrolment of the same serial after this record last synced: this is the old record.
  $status = $null; $next = $null; $notes = @()
  if (Test-RealSerial $d.serialNumber) {
    $newest = $newestBySerial[([string]$d.serialNumber).Trim().ToUpper()]
    if ($newest.Id -ne $d.id -and $newest.Enrolled -and (-not $sync -or $sync -lt $newest.Enrolled)) {
      $status = 'Duplicate'
      $next = 'Delete this Intune record. The same hardware re-enrolled as a newer record.'
      $notes += "Newer record '$($newest.Name)' enrolled $(Format-Date $newest.Enrolled)."
    }
  }
  if (-not $status -and ($null -eq $syncDays -or $syncDays -ge $StaleDays)) {
    if ($null -ne $seenDays -and $seenDays -lt $StaleDays) {
      $status = 'Check'
      $next = "Don't delete it. It still signs in to Entra, so it's probably in use with a broken Intune enrolment. Sync or re-enrol it."
    } else {
      $status = 'Stale'
      $next = 'Confirm with the user. If it is gone: retire or delete it in Intune, disable it in Entra, delete it from Entra after a grace period.'
    }
  }
  if (-not $status) { continue }

  Add-Row -Status $status -Name $d.deviceName -User $d.userPrincipalName -Os $d.operatingSystem -OsVersion $d.osVersion `
    -Ownership $d.managedDeviceOwnerType -IntuneSync $sync -EntraSeen $seen -Serial $d.serialNumber `
    -EntraDevice $e -EntraDeviceId $entraId -NextStep $next -Notes $notes
}

# 5. Entra devices with no Intune record that have gone quiet. A blank timestamp proves nothing, so skip those.
foreach ($e in $entra) {
  if (-not $e.deviceId -or $inIntune[[string]$e.deviceId]) { continue }
  $seen = ConvertTo-UtcDate $e.approximateLastSignInDateTime
  $days = Get-DaysAgo $seen
  if ($null -eq $days -or $days -lt $StaleDays) { continue }
  Add-Row -Status 'Stale (Entra only)' -Name $e.displayName -Os $e.operatingSystem -OsVersion $e.operatingSystemVersion `
    -EntraSeen $seen -EntraDevice $e -EntraDeviceId ([string]$e.deviceId) `
    -NextStep 'Disable it in Entra, then delete it after a grace period.'
}

# Output
$order = @{ 'Check' = 0; 'Duplicate' = 1; 'Stale' = 2; 'Stale (Entra only)' = 3 }
$sorted = @($rows | Sort-Object @{ Expression = { $order[$_.Status] } }, @{ Expression = { if ($null -eq $_.IntuneDays) { [int]::MaxValue } else { $_.IntuneDays } }; Descending = $true })

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, IntuneLastSync, EntraLastSeen, BitLockerKeys, Autopilot -AutoSize | Out-Host
}

$counts = $sorted | Group-Object Status | ForEach-Object { "{0} {1}" -f $_.Count, $_.Name }
Write-Host ("{0} Intune and {1} Entra devices checked against {2} days." -f $intune.Count, $entra.Count, $StaleDays)
if ($sorted.Count -gt 0) {
  Write-Host ("Found: {0}. CSV: {1}" -f ($counts -join ', '), $OutputPath)
  if (@($sorted | Where-Object Status -eq 'Check').Count) {
    Write-Host 'Check = still signing in to Entra but not to Intune. Fix these, do not delete them.' -ForegroundColor Yellow
  }
} else {
  Write-Host "Nothing stale. Every device has checked in within $StaleDays days."
}
Write-Host 'Catch devices going quiet every night instead: https://atliso.com/pro?ref=stale-device-audit' -ForegroundColor Cyan
Write-Log ("Run finished: {0} flagged" -f $sorted.Count)

if ($sorted.Count -gt 0) { exit 1 } else { exit 0 }