Atliso-NoncompliantDeviceAudit.ps1

<#PSScriptInfo
.VERSION 1.0.0
.GUID 6e8c6c5a-9c73-49f8-ad34-acb821957c97
.AUTHOR Atliso
.COMPANYNAME Atliso
.COPYRIGHT (c) 2026 Atliso. MIT License.
.TAGS Intune Compliance Noncompliant GracePeriod ConditionalAccess Graph Audit ReadOnly
.LICENSEURI https://github.com/Atliso-Dev/noncompliant-device-audit/blob/main/LICENSE
.PROJECTURI https://atliso.com/blog/intune-noncompliant-devices-report
.REQUIREDMODULES Microsoft.Graph.Authentication
.EXTERNALMODULEDEPENDENCIES
.REQUIREDSCRIPTS
.EXTERNALSCRIPTDEPENDENCIES
.RELEASENOTES First release.
#>


<#
.SYNOPSIS
  Atliso Noncompliant Device Audit. Read-only.
.DESCRIPTION
  Lists every Intune device that is non-compliant, in its grace period, or in error, with the
  policy and setting that fails, when the grace period ends (Conditional Access blocks the user
  from then), and whether Intune has an email address to warn the user. Also shows what each
  compliance policy does when a device fails, and who it tells.
  Exports a CSV and exits 1 when anything is found. Changes nothing in your tenant.
  https://atliso.com/blog/intune-noncompliant-devices-report
.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, DeviceManagementConfiguration.Read.All
  Exit 0 = every device compliant. Exit 1 = non-compliant, grace period or error devices found.
#>

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

# ===== CONFIG =====
$OutputPath    = Join-Path $env:USERPROFILE 'Desktop\Atliso-NoncompliantDevices.csv'
$CheckSettings = $true   # look up the failing setting per device: one extra Graph call per failing policy
$ShowPolicies  = $true   # list what each compliance policy does on failure, and who it tells
$LogPath       = Join-Path $env:LOCALAPPDATA 'Atliso\NoncompliantDeviceAudit.log'
# ==================

$ErrorActionPreference = 'Stop'

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) {
  for ($try = 1; $try -le 5; $try++) {
    try {
      return Invoke-MgGraphRequest -Method GET -Uri $Uri -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) {
  $items = New-Object System.Collections.Generic.List[object]
  while ($Uri) {
    $page = Invoke-Graph $Uri
    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 or 9999-12-31 for dates that were never set. Treat those 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 -or $d.Year -gt 9000) { return $null }
  return $d
}

$now = (Get-Date).ToUniversalTime()
function Format-Date($Date) { if ($Date) { $Date.ToString('yyyy-MM-dd HH:mm') + ' UTC' } else { '' } }

# Plain names for the settings that fail most often. Anything else shows its Graph name without the policy prefix.
$SettingNames = @{
  'RequireRemainContact'                  = 'Is active: no check-in within the validity period'
  'RequireDeviceCompliancePolicyAssigned' = 'No compliance policy assigned'
  'RequireUserExistence'                  = 'Enrolled user no longer exists'
  'OsMinimumVersion'                      = 'OS older than the minimum version'
  'OsMaximumVersion'                      = 'OS newer than the maximum version'
  'MobileOsMinimumVersion'                = 'OS older than the minimum version'
  'ValidOperatingSystemBuildRanges'       = 'OS build outside the allowed ranges'
  'BitLockerEnabled'                      = 'BitLocker not on'
  'StorageRequireEncryption'              = 'Storage not encrypted'
  'SecureBootEnabled'                     = 'Secure Boot off'
  'CodeIntegrityEnabled'                  = 'Code integrity off'
  'TpmRequired'                           = 'No TPM'
  'ActiveFirewallRequired'                = 'Firewall off'
  'DefenderEnabled'                       = 'Microsoft Defender off'
  'RtpEnabled'                            = 'Defender real-time protection off'
  'SignatureOutOfDate'                    = 'Defender signatures out of date'
  'AntivirusRequired'                     = 'No antivirus registered'
  'AntiSpywareRequired'                   = 'No antispyware registered'
  'DeviceThreatProtectionEnabled'         = 'Defender for Endpoint risk too high'
  'PasswordRequired'                      = 'No password'
  'PasswordMinimumLength'                 = 'Password too short'
  'PasswordRequiredType'                  = 'Password type not allowed'
  'DeviceCompliancePolicyScript'          = 'Custom compliance script failed'
}
function Get-SettingName([string]$Setting) {
  $short = ($Setting -split '\.')[-1]
  if ($SettingNames.ContainsKey($short)) { return $SettingNames[$short] }
  return $short
}

Write-Log 'Run started'
$connect = @{ Scopes = @('DeviceManagementManagedDevices.Read.All', 'DeviceManagementConfiguration.Read.All'); NoWelcome = $true }
if ($TenantId) { $connect.TenantId = $TenantId }
Connect-MgGraph @connect

# 1. Devices that aren't compliant. Unknown and not-evaluated devices are left out: they haven't reported yet.
$states = @('noncompliant', 'inGracePeriod', 'error', 'conflict')
$filter = ($states | ForEach-Object { "complianceState eq '$_'" }) -join ' or '
$select = 'id,deviceName,userPrincipalName,emailAddress,operatingSystem,osVersion,managedDeviceOwnerType,lastSyncDateTime,complianceState,complianceGracePeriodExpirationDateTime'
$devices = Get-GraphList ("https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter={0}&`$select={1}" -f [uri]::EscapeDataString($filter), $select)
Write-Log ("Read {0} devices that aren't compliant" -f $devices.Count)

$rows = New-Object System.Collections.Generic.List[object]
foreach ($d in $devices) {
  $policies = @(); $settings = @()
  if ($CheckSettings) {
    try {
      # Per-device policy and setting states are only in the beta endpoint.
      $policyStates = Get-GraphList "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates"
      foreach ($p in $policyStates) {
        if (@('compliant', 'notApplicable', 'unknown') -contains [string]$p.state) { continue }
        $policies += [string]$p.displayName
        foreach ($s in (Get-GraphList "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($d.id)/deviceCompliancePolicyStates/$($p.id)/settingStates")) {
          if (@('compliant', 'notApplicable', 'remediated') -contains [string]$s.state) { continue }
          $name = Get-SettingName ([string]$s.settingName)
          if ([string]$s.state -ne 'nonCompliant') { $name += " ($($s.state))" }
          if ($settings -notcontains $name) { $settings += $name }
        }
      }
    } catch {
      Write-Log "Setting lookup failed for $($d.deviceName): $($_.Exception.Message)"
    }
  }

  $sync = ConvertTo-UtcDate $d.lastSyncDateTime
  $grace = ConvertTo-UtcDate $d.complianceGracePeriodExpirationDateTime
  $syncDays = $null; if ($sync) { $syncDays = [int][math]::Floor(($now - $sync).TotalDays) }
  $state = [string]$d.complianceState
  $hoursLeft = $null
  $notes = @()

  switch ($state) {
    'inGracePeriod' {
      $status = 'In grace period'
      if ($grace) { $hoursLeft = [math]::Max(0, [int][math]::Floor(($grace - $now).TotalHours)) }
      $next = 'Fix it before the grace period ends. After that, Conditional Access treats it as non-compliant.'
    }
    'noncompliant' {
      $status = 'Noncompliant'
      $next = 'Fix the failing setting, then sync the device. Conditional Access is blocking it if you require compliant devices.'
    }
    default {
      $status = if ($state -eq 'error') { 'Error' } else { 'Conflict' }
      $next = 'Open the device in Intune > Device compliance. Error and conflict usually mean two policies disagree or a setting failed to evaluate.'
    }
  }
  if ($settings -contains $SettingNames['RequireRemainContact'] -or ($null -ne $syncDays -and $syncDays -ge 30)) {
    $notes += "Last check-in $syncDays days ago. If it's still in use, fix the enrolment; if it's gone, clean it up."
  }
  if (-not $d.emailAddress) { $notes += "Intune has no email address for this user, so 'Send email to end user' can't reach them." }

  $rows.Add([pscustomobject]@{
    Status          = $status
    DeviceName      = $d.deviceName
    User            = $d.userPrincipalName
    OS              = $d.operatingSystem
    OSVersion       = $d.osVersion
    Ownership       = $d.managedDeviceOwnerType
    FailingSettings = ($settings -join '; ')
    Policies        = ($policies -join '; ')
    GraceEnds       = Format-Date $grace
    HoursLeft       = $hoursLeft
    LastSync        = Format-Date $sync
    DaysSinceSync   = $syncDays
    NextStep        = $next
    Note            = ($notes -join ' ')
  })
}

# 2. What each policy does when a device fails, and who hears about it
$policySummary = @()
if ($ShowPolicies) {
  try {
    $templates = @{}
    foreach ($t in (Get-GraphList 'https://graph.microsoft.com/beta/deviceManagement/notificationMessageTemplates?$select=id,displayName')) { $templates[[string]$t.id] = $t.displayName }
    $all = Get-GraphList 'https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?$expand=assignments,scheduledActionsForRule($expand=scheduledActionConfigurations)'
    foreach ($p in $all) {
      $actions = @($p.scheduledActionsForRule | ForEach-Object { $_.scheduledActionConfigurations })
      $block = $actions | Where-Object actionType -eq 'block' | Select-Object -First 1
      $emails = @($actions | Where-Object actionType -eq 'notification')
      $copied = @($emails | Where-Object { @($_.notificationMessageCCList).Count -gt 0 })
      $graceHours = if ($block) { [double]$block.gracePeriodHours } else { 0 }
      $policySummary += [pscustomobject]@{
        Policy        = $p.displayName
        Assigned      = (@($p.assignments).Count -gt 0)
        MarkedAfter   = if ($graceHours -eq 0) { 'Immediately' } elseif ($graceHours % 24 -eq 0) { "$($graceHours / 24) days" } else { "$graceHours hours" }
        EmailsUser    = if ($emails.Count) { ($emails | ForEach-Object { 'day ' + [math]::Round($_.gracePeriodHours / 24, 2) }) -join ', ' } else { 'No' }
        CopiesIT      = if ($copied.Count) { 'Yes' } else { 'No' }
        OtherActions  = (@($actions | Where-Object { $_.actionType -notin @('block', 'notification') } | ForEach-Object { $_.actionType }) -join ', ')
      }
    }
  } catch {
    Write-Log "Policy read failed: $($_.Exception.Message)"
    Write-Host "Could not read compliance policies: $($_.Exception.Message)" -ForegroundColor Yellow
  }
}

# Output: grace period first, soonest deadline at the top, then non-compliant, then errors.
$order = @{ 'In grace period' = 0; 'Noncompliant' = 1; 'Error' = 2; 'Conflict' = 3 }
$sorted = @($rows | Sort-Object @{ Expression = { $order[$_.Status] } }, @{ Expression = { if ($null -eq $_.HoursLeft) { [int]::MaxValue } else { $_.HoursLeft } } }, DeviceName)

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, FailingSettings, GraceEnds, LastSync -AutoSize | Out-String -Width 250 | Write-Host
}

if ($policySummary.Count) {
  Write-Host 'What each compliance policy does when a device fails:'
  $policySummary | Format-Table Policy, Assigned, MarkedAfter, EmailsUser, CopiesIT, OtherActions -AutoSize | Out-String -Width 250 | Write-Host
  if (-not @($policySummary | Where-Object CopiesIT -eq 'Yes').Count) {
    Write-Host 'No policy copies anyone in IT on its emails. Users hear about non-compliance first, or nobody does.' -ForegroundColor Yellow
  }
}

$counts = $sorted | Group-Object Status | ForEach-Object { "{0} {1}" -f $_.Count, $_.Name.ToLower() }
if ($sorted.Count -gt 0) {
  Write-Host ("Found: {0}. CSV: {1}" -f ($counts -join ', '), $OutputPath)
} else {
  Write-Host 'Every device that has reported is compliant.'
}
Write-Host 'Get this list every morning without running anything: https://atliso.com/pro?ref=noncompliant-device-audit' -ForegroundColor Cyan
Write-Log ("Run finished: {0} flagged" -f $sorted.Count)

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