Atliso-Win11EndOfSupportAudit.ps1

<#PSScriptInfo
.VERSION 1.0.2
.GUID a5de3ed9-0800-47bc-b68e-092904db644e
.AUTHOR Atliso
.COMPANYNAME Atliso
.COPYRIGHT (c) 2026 Atliso. MIT License.
.TAGS Intune Windows11 Lifecycle EndOfSupport 24H2 Graph Audit ReadOnly
.LICENSEURI https://github.com/Atliso-Dev/win11-eos-audit/blob/main/LICENSE
.PROJECTURI https://atliso.com/blog/windows-11-24h2-end-of-support-intune
.REQUIREDMODULES Microsoft.Graph.Authentication
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES Gallery metadata added. Script logic unchanged from v1.0.1.
#>


<#
.SYNOPSIS
  Atliso Windows 11 End of Support Audit. Read-only.

.DESCRIPTION
  Lists every Intune-managed Windows device with its version, edition and the last day of
  security updates for that edition, and exports the result to CSV.

  Windows 11 support is split by edition, not by build. 24H2 is build 26100 on every edition,
  but Home and Pro lose support on 13 October 2026 while Enterprise and Education keep it until
  12 October 2027, and LTSC 2024 (also 26100) is on a separate long-term lifecycle. Filtering
  Intune by OS version alone gives the wrong list. This script works out the date per device.

  Read-only: it signs in with your own account and only reads Intune's managed devices.
  Nothing in your tenant is changed, so there is no WhatIf switch.

  Guide: https://atliso.com/blog/windows-11-24h2-end-of-support-intune

.PARAMETER TenantId
  Optional. Sign in to a specific tenant (MSPs: run once per customer tenant).

.NOTES
  Requires the Microsoft.Graph.Authentication module:
    Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
  Delegated permission: DeviceManagementManagedDevices.Read.All (Intune Read Only Operator is enough).
  Runs on PowerShell 5.1 and 7.
  Exit 0 = nothing out of support or ending within $WarnDays. Exit 1 = devices need action.
  MIT licensed. https://github.com/Atliso-Dev/win11-eos-audit
#>

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

# ===== CONFIG =====
$OutputPath = Join-Path $env:USERPROFILE 'Desktop\Atliso-Win11-EndOfSupport.csv'
$WarnDays   = 60     # flag devices whose support ends within this many days
$LogPath    = Join-Path $env:LOCALAPPDATA 'Atliso\Win11EndOfSupportAudit.log'
# ==================

# Edition (GetProductInfo product type, reported by Intune as skuNumber) decides the support track.
$ConsumerSkus   = 98,99,100,101,48,49,161,162,164,165   # Home, Pro, Pro for Workstations, Pro Education
$SeSkus         = 178,179                               # Windows 11 SE: 24H2 is its last version
$EnterpriseSkus = 4,27,72,84,121,122,175,188            # Enterprise (+ N, Evaluation), Education, multi-session, IoT Enterprise
$LtscSkus       = 125,126,129,130,191                   # LTSC (+ N, Evaluation), IoT LTSC: separate lifecycle, never flagged

# Last supported day per Microsoft's lifecycle pages. Matched on the exact build:
# a build that isn't listed is reported as Unknown rather than given another release's dates.
$Lifecycle = @{
  26300 = @{ Release = '26H2'; Consumer = '2028-10-09'; Enterprise = '2029-10-09' }
  26200 = @{ Release = '25H2'; Consumer = '2027-10-12'; Enterprise = '2028-10-10' }
  26100 = @{ Release = '24H2'; Consumer = '2026-10-13'; Enterprise = '2027-10-12' }
  22631 = @{ Release = '23H2'; Consumer = '2025-11-11'; Enterprise = '2026-11-10' }
  22621 = @{ Release = '22H2'; Consumer = '2024-10-08'; Enterprise = '2025-10-14' }
  22000 = @{ Release = '21H2'; Consumer = '2023-10-10'; Enterprise = '2024-10-08' }
}

# Licences that step Pro up to Enterprise through subscription activation.
$EnterpriseLicencePatterns = '^SPE_E[35]', '^MICROSOFT_365_E[35]', '^WIN10_PRO_ENT_SUB', '^WIN10_VDA_E[35]', '^WINE5', '^WIN_ENT_E[35]', '^M365EDU_A[35]', '^WIN10_EDU_A[35]'

function Write-Log([string]$Message) {
  $dir = Split-Path $LogPath
  if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
  "$(Get-Date -Format s) $Message" | Add-Content -Path $LogPath
  Write-Verbose $Message
}

# Status code and Retry-After from a failed Graph call, on both PowerShell 5.1 and 7.
function Get-ThrottleWait($ErrorRecord) {
  $resp = $ErrorRecord.Exception.Response
  $code = $null
  if ($resp -and $resp.StatusCode) { $code = [int]$resp.StatusCode }
  elseif ("$ErrorRecord" -match 'TooManyRequests|\b429\b') { $code = 429 }
  if ($code -ne 429) { return $null }
  $wait = 10
  try {
    if ($resp.Headers.RetryAfter.Delta) { $wait = [int][Math]::Ceiling($resp.Headers.RetryAfter.Delta.TotalSeconds) }
    elseif ($resp.Headers['Retry-After']) { $wait = [int]$resp.Headers['Retry-After'] }
  } catch { }
  [Math]::Max(1, $wait)
}

function Get-AllPages([string]$Uri) {
  $items = New-Object System.Collections.Generic.List[object]
  $retries = 0
  while ($Uri) {
    try {
      $page = Invoke-MgGraphRequest -Method GET -Uri $Uri -OutputType PSObject
      $retries = 0
    } catch {
      $wait = Get-ThrottleWait $_
      if ($null -ne $wait -and $retries -lt 5) {
        $retries++
        Write-Log "Throttled by Graph, waiting $wait s (retry $retries)"
        Start-Sleep -Seconds $wait
        continue
      }
      throw
    }
    foreach ($v in @($page.value)) { $items.Add($v) }
    $Uri = $page.'@odata.nextLink'
  }
  $items
}

# Optional: does the tenant hold a licence that should make these devices Enterprise? Needs Organization.Read.All,
# which this script doesn't ask for. If the token happens to carry it, use it; otherwise skip quietly.
function Test-EnterpriseLicensed {
  try {
    $skus = Get-AllPages 'https://graph.microsoft.com/v1.0/subscribedSkus?$select=skuPartNumber,capabilityStatus'
    foreach ($s in $skus) {
      if ($s.capabilityStatus -ne 'Enabled') { continue }
      foreach ($p in $EnterpriseLicencePatterns) { if ($s.skuPartNumber -match $p) { return $true } }
    }
    return $false
  } catch {
    Write-Log 'Licence check skipped (no Organization.Read.All on this token)'
    return $false
  }
}

$connect = @{ Scopes = 'DeviceManagementManagedDevices.Read.All'; NoWelcome = $true }
if ($TenantId) { $connect.TenantId = $TenantId }
Connect-MgGraph @connect
Write-Log 'Connected to Microsoft Graph'

# Edition fields (skuNumber, skuFamily) are on the beta managedDevice resource.
$select  = 'id,deviceName,userPrincipalName,operatingSystem,osVersion,skuFamily,skuNumber,lastSyncDateTime'
$devices = @(Get-AllPages "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=$select" |
  Where-Object { $_.operatingSystem -eq 'Windows' })
Write-Log "Read $($devices.Count) Windows devices"

$entLicensed = Test-EnterpriseLicensed
$today = (Get-Date).Date

$rows = foreach ($d in $devices) {
  $build = $null
  if ($d.osVersion -match '^10\.0\.(\d+)') { $build = [int]$Matches[1] }
  $sku = 0
  if ($null -ne $d.skuNumber) { $sku = [int]$d.skuNumber }

  $track = 'Unknown'
  $family = [string]$d.skuFamily
  if ($LtscSkus -contains $sku) { $track = 'LTSC' }
  elseif ($EnterpriseSkus -contains $sku) { $track = 'Enterprise' }
  elseif ($SeSkus -contains $sku) { $track = 'SE' }
  elseif ($ConsumerSkus -contains $sku) { $track = 'HomePro' }
  # A product type not listed above: fall back to the edition name Intune reports alongside it.
  elseif ($family -match 'EnterpriseS|LTSC') { $track = 'LTSC' }
  elseif ($family -match '^(Enterprise|Education|IoTEnterprise)') { $track = 'Enterprise' }
  elseif ($family -match '^(Pro|Professional|Core|Home)') { $track = 'HomePro' }

  $life = $null
  if ($build -and $Lifecycle.ContainsKey($build)) { $life = $Lifecycle[$build] }
  $release = 'Unknown'
  if ($life) { $release = $life.Release } elseif ($build -and $build -lt 22000) { $release = 'Windows 10' }

  $lastDay = $null; $daysLeft = $null; $status = 'Unknown'; $note = ''
  if ($track -eq 'LTSC') {
    $status = 'LTSC'; $note = 'Long-term servicing, separate lifecycle'
  } elseif ($release -eq 'Windows 10') {
    $status = 'OutOfSupport'; $note = 'Windows 10 ended 14 Oct 2025 unless enrolled in ESU'
  } elseif (-not $life) {
    $note = 'Build not in the lifecycle table, check manually'
  } elseif ($track -eq 'Unknown') {
    $note = $(if ($family) { "Edition $family (SKU $sku) not recognised, check the device" } else { 'Edition not reported, check the device' })
  } else {
    $lastDay = [datetime]::ParseExact($(if ($track -eq 'Enterprise') { $life.Enterprise } else { $life.Consumer }), 'yyyy-MM-dd', $null)
    $daysLeft = ($lastDay - $today).Days
    if ($daysLeft -lt 0) { $status = 'OutOfSupport' } elseif ($daysLeft -le $WarnDays) { $status = 'EndingSoon' } else { $status = 'Supported' }
    if ($track -eq 'SE' -and $release -eq '24H2') { $note = 'SE: 24H2 is the last version, replace or reimage' }
    elseif ($track -eq 'HomePro' -and $entLicensed) { $note = 'Pro edition in an Enterprise-licensed tenant: check subscription activation' }
  }

  [pscustomobject]@{
    DeviceName        = $d.deviceName
    UserPrincipalName = $d.userPrincipalName
    OSVersion         = $d.osVersion
    Release           = $release
    Edition           = $d.skuFamily
    SkuNumber         = $d.skuNumber
    SupportTrack      = $track
    LastSupportedDay  = $(if ($lastDay) { $lastDay.ToString('yyyy-MM-dd') } else { '' })
    DaysLeft          = $daysLeft
    Status            = $status
    Note              = $note
    LastSync          = $d.lastSyncDateTime
  }
}
$rows = @($rows)

# Out of support first (Windows 10 has no countdown), then soonest to end, then the rest.
$sorted = $rows | Sort-Object @{ Expression = {
    if ($_.Status -eq 'OutOfSupport' -and $null -eq $_.DaysLeft) { [int]::MinValue }
    elseif ($null -ne $_.DaysLeft) { $_.DaysLeft }
    else { [int]::MaxValue } } }, DeviceName
$sorted | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8

Write-Host ''
Write-Host "Windows devices: $($rows.Count)"
$rows | Group-Object Status | Sort-Object Name | Format-Table @{ L = 'Status'; E = { $_.Name } }, Count -AutoSize | Out-Host
$soonest = @($sorted | Where-Object { $_.Status -in 'OutOfSupport', 'EndingSoon' } | Select-Object -First 10)
if ($soonest.Count) {
  Write-Host 'Soonest to lose support:'
  $soonest | Format-Table DeviceName, Release, Edition, LastSupportedDay, DaysLeft, Status -AutoSize | Out-Host
}
Write-Host "CSV: $OutputPath"

$needAction = @($rows | Where-Object { $_.Status -in 'OutOfSupport', 'EndingSoon' }).Count
Write-Log "Done. $($rows.Count) Windows devices, $needAction need action"
if ($needAction) { exit 1 } else { exit 0 }