Microsoft Intune PowerShell script Report only

Get-IntuneDefenderAVStatus.ps1

Microsoft Defender Antivirus health report for Intune-managed Windows devices.

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#

ParameterWhat it does
-DeviceNameWildcard pattern applied to the Intune device name, for example 'LT-*'.
-OnlyProblemsReturn only devices whose HealthStatus is not Healthy (including NoDataReported).
-OutputPathCSV file to create. Defaults to .\Reports\IntuneDefenderAVStatus_yyyyMMdd-HHmm.csv; the folder is created when missing.
-PassThruAlso emit the report objects to the pipeline.

Examples#

PowerShell
PS> .\Get-IntuneDefenderAVStatus.ps1

Exports the Defender Antivirus state of every Windows device and prints counts per HealthStatus.

PowerShell
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.

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#

PowerShell · Get-IntuneDefenderAVStatus.ps1
<#
.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

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.