Overview#
Lists Windows devices from Microsoft Graph (v1.0 /deviceManagement/managedDevices) and reads the beta /windowsProtectionState of each device: real-time and malware protection, tamper protection, signature/engine versions, overdue signatures and scans, pending reboot and last report time. Each device is classified as Healthy, AttentionNeeded, Critical or NoDataReported (HealthStatus) with the individual findings in an Issues column. Exports to CSV and prints counts per status.
Safety: Report only — makes no changes to your tenant. Run Get-Help .\Get-IntuneDefenderAVStatus.ps1 -Full for the complete help text.
Parameters#
| Parameter | What it does |
|---|---|
-DeviceName | Wildcard pattern applied to the Intune device name, for example 'LT-*'. |
-OnlyProblems | Return only devices whose HealthStatus is not Healthy (including NoDataReported). |
-OutputPath | CSV file to create. Defaults to .\Reports\IntuneDefenderAVStatus_yyyyMMdd-HHmm.csv; the folder is created when missing. |
-PassThru | Also emit the report objects to the pipeline. |
Examples#
PS> .\Get-IntuneDefenderAVStatus.ps1Exports the Defender Antivirus state of every Windows device and prints counts per HealthStatus.
PS> .\Get-IntuneDefenderAVStatus.ps1 -OnlyProblems -PassThru | Where-Object { $_.HealthStatus -eq 'Critical' } | Format-Table DeviceName, UserPrincipalName, IssuesLists the devices on which malware or real-time protection is disabled.
Permissions, modules and notes#
Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication Permissions : DeviceManagementManagedDevices.Read.All (delegated) plus an Intune RBAC role such as Read Only Operator Notes : windowsProtectionState exists only on the beta endpoint and may change without notice. The script makes one Graph call per Windows device with a 200 ms pause, so expect roughly 4-5 devices per second (about 4 minutes per 1,000 devices); the SDK retries HTTP 429 automatically. Devices that have not yet reported Defender state return HTTP 404 and are listed as NoDataReported. Protection state is refreshed at device check-in; a Critical finding should be confirmed on the device before acting on it.
Full source#
<#
.SYNOPSIS
Microsoft Defender Antivirus health report for Intune-managed Windows devices.
.DESCRIPTION
Lists Windows devices from Microsoft Graph (v1.0 /deviceManagement/managedDevices) and reads the
beta /windowsProtectionState of each device: real-time and malware protection, tamper protection,
signature/engine versions, overdue signatures and scans, pending reboot and last report time.
Each device is classified as Healthy, AttentionNeeded, Critical or NoDataReported (HealthStatus)
with the individual findings in an Issues column. Exports to CSV and prints counts per status.
.PARAMETER DeviceName
Wildcard pattern applied to the Intune device name, for example 'LT-*'.
.PARAMETER OnlyProblems
Return only devices whose HealthStatus is not Healthy (including NoDataReported).
.PARAMETER OutputPath
CSV file to create. Defaults to .\Reports\IntuneDefenderAVStatus_yyyyMMdd-HHmm.csv; the folder is created when missing.
.PARAMETER PassThru
Also emit the report objects to the pipeline.
.EXAMPLE
PS> .\Get-IntuneDefenderAVStatus.ps1
Exports the Defender Antivirus state of every Windows device and prints counts per HealthStatus.
.EXAMPLE
PS> .\Get-IntuneDefenderAVStatus.ps1 -OnlyProblems -PassThru | Where-Object { $_.HealthStatus -eq 'Critical' } | Format-Table DeviceName, UserPrincipalName, Issues
Lists the devices on which malware or real-time protection is disabled.
.NOTES
Author : Omer Eltayeb
Blog : https://www.oeltayeb.com
GitHub : https://github.com/omer-eltayeb
Version : 1.0.0
Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication
Permissions : DeviceManagementManagedDevices.Read.All (delegated) plus an Intune RBAC role such as Read Only Operator
Notes : windowsProtectionState exists only on the beta endpoint and may change without notice. The script
makes one Graph call per Windows device with a 200 ms pause, so expect roughly 4-5 devices per second
(about 4 minutes per 1,000 devices); the SDK retries HTTP 429 automatically. Devices that have not yet
reported Defender state return HTTP 404 and are listed as NoDataReported. Protection state is refreshed
at device check-in; a Critical finding should be confirmed on the device before acting on it.
.LINK
https://learn.microsoft.com/graph/api/intune-devices-windowsprotectionstate-get?view=graph-rest-beta
#>
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication
[CmdletBinding()]
param(
[Parameter()]
[string]$DeviceName,
[Parameter()]
[switch]$OnlyProblems,
[Parameter()]
[string]$OutputPath,
[Parameter()]
[switch]$PassThru
)
$ErrorActionPreference = 'Stop'
#region Helpers
function Connect-GraphIfNeeded {
<# Connects to Microsoft Graph only when there is no usable session for the required scopes. #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Scopes
)
$context = Get-MgContext
$missingScopes = @()
if ($null -ne $context) {
$missingScopes = @($Scopes | Where-Object { $context.Scopes -notcontains $_ })
}
if ($null -eq $context -or $missingScopes.Count -gt 0) {
Write-Verbose "Connecting to Microsoft Graph with scopes: $($Scopes -join ', ')"
Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop | Out-Null
}
else {
Write-Verbose "Reusing existing Microsoft Graph session for $($context.Account)."
}
}
function Invoke-GraphPaged {
<# GET helper that follows @odata.nextLink and returns every item in 'value'. #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Uri,
[Parameter()]
[hashtable]$Headers
)
$results = New-Object -TypeName System.Collections.Generic.List[object]
$nextLink = $Uri
while (-not [string]::IsNullOrEmpty($nextLink)) {
$requestParams = @{ Method = 'GET'; Uri = $nextLink; OutputType = 'PSObject'; ErrorAction = 'Stop' }
if ($null -ne $Headers) { $requestParams['Headers'] = $Headers }
$response = Invoke-MgGraphRequest @requestParams
if ($null -ne $response.PSObject.Properties['value']) {
foreach ($item in $response.value) { $results.Add($item) }
}
elseif ($null -ne $response) {
$results.Add($response)
}
$nextLink = $response.'@odata.nextLink'
}
return $results
}
function ConvertTo-UtcDateTime {
<# Normalises a Graph date value (string or DateTime) to a UTC [datetime]; returns $null for empty or 0001-01-01 placeholders. #>
[CmdletBinding()]
param(
[Parameter()]
[object]$Value
)
if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { return $null }
try { $parsed = [datetime]$Value } catch { return $null }
if ($parsed.Year -le 1) { return $null }
return $parsed.ToUniversalTime()
}
function Test-GraphNotFoundError {
<# Returns $true when an Invoke-MgGraphRequest error represents HTTP 404 (resource missing or not yet reported). #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[System.Management.Automation.ErrorRecord]$ErrorRecord
)
$text = '{0} {1}' -f $ErrorRecord.Exception.Message, $ErrorRecord.ErrorDetails.Message
return ($text -match 'NotFound|\b404\b|does not exist')
}
#endregion Helpers
#region Main
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
$OutputPath = Join-Path -Path $reportFolder -ChildPath ('IntuneDefenderAVStatus_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}
try {
Connect-GraphIfNeeded -Scopes @('DeviceManagementManagedDevices.Read.All')
}
catch {
throw "Failed to connect to Microsoft Graph: $($_.Exception.Message)"
}
$graphV1 = 'https://graph.microsoft.com/v1.0'
$graphBeta = 'https://graph.microsoft.com/beta' # beta: windowsProtectionState is not exposed on the v1.0 managedDevice
$deviceUri = $graphV1 + '/deviceManagement/managedDevices?$select=id,deviceName,userPrincipalName,osVersion,lastSyncDateTime,complianceState&$filter=operatingSystem eq ''Windows'''
try {
$devices = @(Invoke-GraphPaged -Uri $deviceUri)
}
catch {
throw "Failed to retrieve Windows managed devices from Microsoft Graph: $($_.Exception.Message)"
}
if (-not [string]::IsNullOrWhiteSpace($DeviceName)) {
$devices = @($devices | Where-Object { $_.deviceName -like $DeviceName })
}
Write-Verbose ('{0} Windows devices selected.' -f $devices.Count)
if ($devices.Count -eq 0) {
Write-Warning 'No Windows managed devices matched the specified criteria; nothing to report.'
return
}
$report = New-Object -TypeName System.Collections.Generic.List[object]
$index = 0
foreach ($device in $devices) {
$index++
Write-Progress -Activity 'Reading Defender Antivirus state' -Status ('{0} of {1}: {2}' -f $index, $devices.Count, $device.deviceName) -PercentComplete ([int](($index / $devices.Count) * 100))
$state = $null
$errorMessage = $null
try {
$state = Invoke-MgGraphRequest -Method GET -Uri ('{0}/deviceManagement/managedDevices/{1}/windowsProtectionState' -f $graphBeta, $device.id) -OutputType PSObject -ErrorAction Stop
}
catch {
if (Test-GraphNotFoundError -ErrorRecord $_) {
Write-Verbose ('{0}: no Windows protection state has been reported yet.' -f $device.deviceName)
}
else {
$errorMessage = $_.Exception.Message
Write-Warning ('{0}: could not read protection state: {1}' -f $device.deviceName, $errorMessage)
}
}
$lastReported = $null
if ($null -ne $state) { $lastReported = ConvertTo-UtcDateTime -Value $state.lastReportedDateTime }
$issues = @()
$healthStatus = 'NoDataReported'
if ($null -ne $lastReported) {
# Disabled protection is critical; everything else is hygiene that needs attention.
if ($state.malwareProtectionEnabled -eq $false) { $issues += 'Malware protection disabled' }
if ($state.realTimeProtectionEnabled -eq $false) { $issues += 'Real-time protection disabled' }
$criticalCount = $issues.Count
if ($state.tamperProtectionEnabled -eq $false) { $issues += 'Tamper protection off' }
if ($state.signatureUpdateOverdue -eq $true) { $issues += 'Signature update overdue' }
if ($state.fullScanOverdue -eq $true) { $issues += 'Full scan overdue' }
if ($state.quickScanOverdue -eq $true) { $issues += 'Quick scan overdue' }
if ($state.rebootRequired -eq $true) { $issues += 'Reboot required' }
if ($criticalCount -gt 0) { $healthStatus = 'Critical' }
elseif ($issues.Count -gt 0) { $healthStatus = 'AttentionNeeded' }
else { $healthStatus = 'Healthy' }
}
$report.Add([PSCustomObject]@{
DeviceName = $device.deviceName
UserPrincipalName = $device.userPrincipalName
OSVersion = $device.osVersion
ComplianceState = $device.complianceState
LastSyncDateTime = ConvertTo-UtcDateTime -Value $device.lastSyncDateTime
HealthStatus = $healthStatus
Issues = ($issues -join '; ')
RealTimeProtectionEnabled = $state.realTimeProtectionEnabled
MalwareProtectionEnabled = $state.malwareProtectionEnabled
NetworkInspectionSystemEnabled = $state.networkInspectionSystemEnabled
TamperProtectionEnabled = $state.tamperProtectionEnabled
IsVirtualMachine = $state.isVirtualMachine
DeviceState = $state.deviceState
ProductStatus = $state.productStatus
AntiMalwareVersion = $state.antiMalwareVersion
EngineVersion = $state.engineVersion
SignatureVersion = $state.signatureVersion
SignatureUpdateOverdue = $state.signatureUpdateOverdue
FullScanOverdue = $state.fullScanOverdue
QuickScanOverdue = $state.quickScanOverdue
RebootRequired = $state.rebootRequired
LastQuickScanDateTime = ConvertTo-UtcDateTime -Value $state.lastQuickScanDateTime
LastFullScanDateTime = ConvertTo-UtcDateTime -Value $state.lastFullScanDateTime
LastReportedDateTime = $lastReported
ManagedDeviceId = $device.id
Error = $errorMessage
})
Start-Sleep -Milliseconds 200
}
Write-Progress -Activity 'Reading Defender Antivirus state' -Completed
$output = $report
if ($OnlyProblems) {
$output = @($report | Where-Object { $_.HealthStatus -ne 'Healthy' })
}
if ($output.Count -gt 0) {
$output | Sort-Object -Property HealthStatus, DeviceName | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
Write-Host ('Report written to {0}' -f $OutputPath) -ForegroundColor Green
}
else {
Write-Warning 'No devices matched the specified criteria; no CSV file was written.'
}
$statusColours = @{ Healthy = 'Green'; AttentionNeeded = 'Yellow'; Critical = 'Red'; NoDataReported = 'Gray' }
Write-Host ''
Write-Host ('Windows devices checked : {0}' -f $report.Count) -ForegroundColor Cyan
foreach ($status in @('Healthy', 'AttentionNeeded', 'Critical', 'NoDataReported')) {
$count = @($report | Where-Object { $_.HealthStatus -eq $status }).Count
Write-Host (' {0,-16} {1,6}' -f $status, $count) -ForegroundColor $statusColours[$status]
}
if ($PassThru) {
$output
}
#endregion Main