Loading_
Loading_
Reports which machines are actually protected — signature age, real-time protection state, tamper protection, exclusion sprawl and last successful scan — across an AD or Intune fleet.
#Requires -Version 7.0<#.SYNOPSIS Fleet-wide Microsoft Defender health and exclusion audit. .DESCRIPTION Read-only. Queries each endpoint for the protection state that actually matters rather than trusting the console's enrolment count. .EXAMPLE .\Get-DefenderHealth.ps1 -ComputerName (Get-ADComputer -Filter *).Name#>[CmdletBinding()]param( [Parameter(Mandatory, ValueFromPipeline)] [string[]] $ComputerName, [int] $MaxSignatureAgeDays = 3, [int] $MaxScanAgeDays = 7, [int] $ThrottleLimit = 32, [string] $OutputPath = ".\defender-health") begin { $targets = New-Object System.Collections.Generic.List[string] }process { $targets.AddRange($ComputerName) } end { New-Item -ItemType Directory -Path $OutputPath -Force | Out-Null Write-Host "Querying $($targets.Count) endpoints..." -ForegroundColor Cyan $results = $targets | ForEach-Object -ThrottleLimit $ThrottleLimit -Parallel { $name = $_ $row = [ordered]@{ ComputerName = $name; Reachable = $false; Error = "" AmServiceEnabled = $null; RealTimeProtection = $null TamperProtection = $null; BehaviorMonitor = $null SignatureAgeDays = $null; SignatureVersion = "" LastFullScan = $null; LastQuickScan = $null PathExclusions = 0; ProcessExclusions = 0; ExtensionExclusions = 0 RiskyExclusions = ""; EngineVersion = "" } try { $session = New-CimSession -ComputerName $name -OperationTimeoutSec 20 -ErrorAction Stop $status = Get-MpComputerStatus -CimSession $session -ErrorAction Stop $prefs = Get-MpPreference -CimSession $session -ErrorAction Stop $row.Reachable = $true $row.AmServiceEnabled = $status.AMServiceEnabled $row.RealTimeProtection = $status.RealTimeProtectionEnabled $row.TamperProtection = $status.IsTamperProtected $row.BehaviorMonitor = $status.BehaviorMonitorEnabled $row.SignatureVersion = $status.AntivirusSignatureVersion $row.EngineVersion = $status.AMEngineVersion $row.SignatureAgeDays = [math]::Round(((Get-Date) - $status.AntivirusSignatureLastUpdated).TotalDays, 1) $row.LastFullScan = $status.FullScanEndTime $row.LastQuickScan = $status.QuickScanEndTime $paths = @($prefs.ExclusionPath) $row.PathExclusions = $paths.Count $row.ProcessExclusions = @($prefs.ExclusionProcess).Count $row.ExtensionExclusions = @($prefs.ExclusionExtension).Count # An exclusion this broad is an uninstall wearing a hat $risky = $paths | Where-Object { $_ -match '^[A-Za-z]:\\?$' -or $_ -match '^[A-Za-z]:\\(Users|Windows|Program Files.*|ProgramData)\\?$' -or $_ -match '\*' } $row.RiskyExclusions = ($risky -join "; ") Remove-CimSession $session } catch { $row.Error = $_.Exception.Message } [pscustomobject] $row } $results | Export-Csv (Join-Path $OutputPath "defender-health.csv") -NoTypeInformation -Encoding UTF8 # ── Findings ───────────────────────────────────────────────────────── $findings = New-Object System.Collections.Generic.List[object] function Add-Finding { param($Computer, $Severity, $Message) $findings.Add([pscustomobject]@{ ComputerName = $Computer; Severity = $Severity; Finding = $Message }) } foreach ($r in $results) { if (-not $r.Reachable) { Add-Finding $r.ComputerName "Medium" "Unreachable: $($r.Error)"; continue } if (-not $r.AmServiceEnabled) { Add-Finding $r.ComputerName "Critical" "Defender service is not running" } if (-not $r.RealTimeProtection) { Add-Finding $r.ComputerName "Critical" "Real-time protection is OFF" } if (-not $r.TamperProtection) { Add-Finding $r.ComputerName "High" "Tamper protection is OFF" } if (-not $r.BehaviorMonitor) { Add-Finding $r.ComputerName "High" "Behaviour monitoring is OFF" } if ($r.RiskyExclusions) { Add-Finding $r.ComputerName "Critical" "Overly broad exclusion: $($r.RiskyExclusions)" } if ($r.SignatureAgeDays -gt $MaxSignatureAgeDays) { Add-Finding $r.ComputerName "High" "Signatures are $($r.SignatureAgeDays) days old" } if ($r.PathExclusions -gt 25) { Add-Finding $r.ComputerName "Medium" "$($r.PathExclusions) path exclusions - review for sprawl" } $lastScan = @($r.LastFullScan, $r.LastQuickScan) | Where-Object { $_ } | Sort-Object -Descending | Select-Object -First 1 if (-not $lastScan) { Add-Finding $r.ComputerName "High" "No scan has ever completed" } elseif (((Get-Date) - $lastScan).TotalDays -gt $MaxScanAgeDays) { Add-Finding $r.ComputerName "Medium" "Last scan was $([int]((Get-Date) - $lastScan).TotalDays) days ago" } } $order = @{ Critical = 0; High = 1; Medium = 2 } $findings | Sort-Object { $order[$_.Severity] }, ComputerName | Export-Csv (Join-Path $OutputPath "findings.csv") -NoTypeInformation -Encoding UTF8 $reachable = ($results | Where-Object Reachable).Count Write-Host "" Write-Host "$reachable of $($targets.Count) endpoints responded." -ForegroundColor Green $findings | Group-Object Severity | Sort-Object { $order[$_.Name] } | ForEach-Object { $colour = switch ($_.Name) { "Critical" { "Red" } "High" { "Yellow" } default { "Gray" } } Write-Host " $($_.Name): $($_.Count)" -ForegroundColor $colour }}An EDR console showing 98% coverage is usually measuring installation, not protection. A machine can be enrolled, reporting healthy, and still have real-time protection disabled by a group policy someone applied in 2019.
This queries each endpoint directly for the state that matters, and treats exclusions as a first-class finding: a wildcard exclusion on C:\ is functionally an uninstall, and it will never show up as a coverage gap.
Runs in parallel across the fleet with a per-host timeout so one unreachable machine does not stall the report, and writes both a CSV and a ranked findings list.
| Name | Type | Required | Description |
|---|---|---|---|
ComputerName | string[] | Required | Endpoints to query. Accepts pipeline input. |
MaxSignatureAgeDays | int | Optional | Age at which signatures are flagged. |
ThrottleLimit | int | Optional | Parallel query fan-out. |
The platform turns any script into a governed automation — versioned, gated, audited and reversible.