public/Get-MsecDefenderDevice.ps1
|
function Get-MsecDefenderDevice { <# .SYNOPSIS Every device onboarded to Defender for Endpoint, with its exposure level and how many vulnerabilities have been discovered on it. .DESCRIPTION The Assets > Devices view in the Defender portal, as flat rows: one per device, with the exposure level and risk score the portal shows, plus the discovered-vulnerability count broken down by severity. TWO BULK CALLS, NOT ONE PER DEVICE. The device list comes from /api/machines and the counts from /api/vulnerabilities/machinesVulnerabilities - one row per device, software and CVE, which returns every (device, software, CVE) finding in the tenant in one paged stream. Asking /api/machines/{id}/vulnerabilities per device would be one request per machine, which on a few thousand devices is a few thousand round trips and a throttling wall. Two streams cost the same whether you have ten devices or ten thousand. VULNERABILITIES ARE COUNTED AS DISTINCT CVEs, which is what the portal shows when you open a device. The export is one row per (software, CVE), so a single CVE affecting three installed versions of the same product is three rows and one vulnerability. Counting rows would inflate every device by a factor that varies with how much software it has. FindingCount carries the raw row count alongside, because the gap between the two is the remediation workload - one CVE fixed in three places. A FAILED VULNERABILITY READ GIVES $null COUNTS, NOT ZERO. Defender Vulnerability Management is a separate licence, and a tenant without it answers 403 on the assessment export. Reporting 0 there would read as "no device has any vulnerability", which is the most dangerous wrong answer this command could give - so the device rows still come back, every count is $null, and a warning says why. A DEVICE WITH NO ROWS IN THE EXPORT REPORTS 0, AND THAT NEEDS READING WITH CARE. The export lists devices that have at least one finding; a device absent from it has none recorded. For an actively reporting device that means clean. For one that stopped talking to the service months ago it means nobody has looked - the same 0. HealthStatus and LastSeen are the columns that separate them, which is why they are on every row rather than left to a second call. Sort on them before reading a 0 as good news. .PARAMETER HealthStatus Only devices in these health states - 'Active', 'Inactive', 'ImpairedCommunication', 'NoSensorData', 'NoSensorDataImpairedCommunication', 'Unknown'. Omit for all of them. Filtering happens after the fetch, so it costs nothing extra and is exact. .PARAMETER ExposureLevel Only devices at these exposure levels - 'None', 'Low', 'Medium', 'High'. Omit for all. .EXAMPLE Connect-Msec -KeyVaultName kv-msec -TenantId <guid> -ClientId <guid> Get-MsecDefenderDevice | Sort-Object VulnerabilityCount -Descending | Select-Object -First 20 .EXAMPLE # The list worth acting on: devices carrying critical CVEs, worst first. Get-MsecDefenderDevice | Where-Object CriticalCount -gt 0 | Sort-Object CriticalCount, HighCount -Descending | Format-Table DeviceName, OsPlatform, ExposureLevel, CriticalCount, HighCount, LastSeen .EXAMPLE # A 0 that means "nobody has looked" rather than "clean". Get-MsecDefenderDevice | Where-Object { $_.VulnerabilityCount -eq 0 -and $_.HealthStatus -ne 'Active' } | Sort-Object LastSeen .EXAMPLE # Remediation workload vs distinct CVEs - the gap is the same fix in several places. Get-MsecDefenderDevice | Select-Object DeviceName, VulnerabilityCount, FindingCount | Sort-Object { $_.FindingCount - $_.VulnerabilityCount } -Descending .OUTPUTS PSCustomObject per device, PSTypeName 'MsecDefenderDevice'. See .NOTES for the projection. .NOTES Needs Connect-Msec, and the WindowsDefenderATP application permissions 'Machine.Read.All' and 'Vulnerability.Read.All'. New-MsecApp grants both; an app created before they were added needs a re-run to pick them up. Defender for Endpoint is COMMERCIAL-ONLY. In a sovereign cloud with no securitycenter endpoint - Azure China, for one - this throws with that explanation rather than reaching for a dead host. Projection (API field -> output property): computerDnsName -> DeviceName id -> Id exposureLevel -> ExposureLevel ('None' / 'Low' / 'Medium' / 'High') riskScore -> RiskScore ('None' / 'Low' / 'Medium' / 'High') <distinct cveId per device> -> VulnerabilityCount <by severity, once per CVE> -> CriticalCount / HighCount / MediumCount / LowCount <raw export rows> -> FindingCount osPlatform / version / osBuild -> OsPlatform / OsVersion / OsBuild healthStatus -> HealthStatus onboardingStatus -> OnboardingStatus lastSeen / firstSeen -> LastSeen / FirstSeen (UTC) rbacGroupName -> RbacGroupName machineTags -> MachineTags aadDeviceId -> AadDeviceId lastIpAddress / lastExternalIpAddress -> LastIpAddress / LastExternalIpAddress <entire machine object> -> Raw Timestamps are normalised to UTC. A plain [datetime] cast of the API's '2026-09-01T08:00:00Z' yields Kind=Local, which then compares wrongly against a UTC clock - an hour or two of skew, and a whole day either side of midnight on any day-count derived from it. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [ValidateSet('Active', 'Inactive', 'ImpairedCommunication', 'NoSensorData', 'NoSensorDataImpairedCommunication', 'Unknown')] [string[]] $HealthStatus, [ValidateSet('None', 'Low', 'Medium', 'High')] [string[]] $ExposureLevel ) Assert-MsecSession # See the note in .NOTES: the API's 'Z' timestamps cast to Kind=Local, which is the right # instant expressed in local wall-clock and therefore wrong against a UTC clock. # AssumeUniversal covers a response that omits the trailing Z. $toUtc = { param($value) if (-not $value) { return $null } if ($value -is [datetime]) { return $value.ToUniversalTime() } [datetime]::Parse([string] $value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal) } # ---- devices -------------------------------------------------------------------------- try { $machines = @(Invoke-MsecDefenderRequest -Path '/api/machines' -All) } catch { $detail = $_.Exception.Message if ($detail -match '403|Forbidden') { throw "Forbidden when calling /api/machines. The msec app needs the WindowsDefenderATP 'Machine.Read.All' application permission (admin consent required). Re-run New-MsecApp to add and consent it. Original error: $detail" } throw } if (-not $machines.Count) { Write-Warning 'No devices are onboarded to Defender for Endpoint in this tenant.' return } # ---- vulnerability assessment ----------------------------------------------------------- # # One row per (device, software, CVE) across the whole tenant. Collected into per-device # tallies as it streams, so the full export is never held in memory at once - on a large # estate it is hundreds of thousands of rows and only the counts are wanted. # # THE ID AND SEVERITY FIELDS ARE READ UNDER BOTH NAMES DEFENDER USES FOR THEM. The two # bulk vulnerability endpoints return the same facts under different keys: # # /api/vulnerabilities/machinesVulnerabilities -> machineId, severity # /api/machines/SoftwareVulnerabilitiesByMachine -> deviceId, vulnerabilitySeverityLevel # # Reading only one pair is how this command first shipped, and against a live tenant it # produced a full device list with every count reading 0 - the call succeeded, every row # streamed in, and every row was discarded for having no id under the name being looked # for. Accepting both costs a coalesce and removes a whole class of silent-zero bug. $byDevice = @{} $vulnerabilitiesRead = $true # Rows that arrived, and rows that could actually be attributed to a device. A large gap # between them means the response is not the shape this code expects, which is the one # failure that must never be reported as "no vulnerabilities" - see the check below. $rowsSeen = 0 $rowsAttributed = 0 try { Invoke-MsecDefenderRequest -Path '/api/vulnerabilities/machinesVulnerabilities' -All | ForEach-Object { $rowsSeen++ $deviceId = [string] (@($_.machineId, $_.deviceId | Where-Object { $_ })[0]) if (-not $deviceId) { return } $rowsAttributed++ if (-not $byDevice.ContainsKey($deviceId)) { $byDevice[$deviceId] = [pscustomobject]@{ Cves = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) Severity = @{} Findings = 0 } } $entry = $byDevice[$deviceId] $entry.Findings++ $cve = [string] $_.cveId if (-not $cve) { return } # Severity is counted ONCE PER CVE, not once per finding - the same CVE across # three installed versions is one vulnerability at one severity. Only the first # sighting of a CVE on a device counts, which is what makes the severity # columns add up to VulnerabilityCount. if ($entry.Cves.Add($cve)) { $level = [string] (@($_.severity, $_.vulnerabilitySeverityLevel | Where-Object { $_ })[0]) if (-not $level) { $level = 'Unknown' } if (-not $entry.Severity.ContainsKey($level)) { $entry.Severity[$level] = 0 } $entry.Severity[$level]++ } } } catch { # Defender Vulnerability Management is a separate licence. Losing the counts must not # lose the device inventory with them, and it must never look like a clean result. $vulnerabilitiesRead = $false $detail = $_.Exception.Message if ($detail -match '403|Forbidden') { Write-Warning "Forbidden reading the vulnerability assessment export. The msec app needs the WindowsDefenderATP 'Vulnerability.Read.All' permission, and the tenant needs Defender Vulnerability Management. Every vulnerability count is reported as null rather than 0. Original error: $detail" } else { Write-Warning "Could not read the vulnerability assessment export, so every vulnerability count is null rather than 0: $detail" } } # ROWS CAME BACK AND NONE OF THEM COULD BE ATTRIBUTED TO A DEVICE. That is a response in a # shape this code does not understand, not an estate with no vulnerabilities - and the two # are indistinguishable in the output unless it is said here. Reported as unread, so every # count is null rather than a tenant-wide row of zeroes that looks like good news. if ($vulnerabilitiesRead -and $rowsSeen -gt 0 -and $rowsAttributed -eq 0) { $vulnerabilitiesRead = $false Write-Warning "The vulnerability export returned $rowsSeen row(s), none carrying a device id under 'machineId' or 'deviceId'. The response is not in the expected shape, so every vulnerability count is reported as null rather than 0. Run this to see what the API actually returned: Invoke-MsecDefenderRequest -Path '/api/vulnerabilities/machinesVulnerabilities' | Select-Object -ExpandProperty value | Select-Object -First 1 | Format-List" } if ($vulnerabilitiesRead) { Write-Verbose "Vulnerability export: $rowsSeen finding(s) across $($byDevice.Count) device(s)." } # ---- project ---------------------------------------------------------------------------- foreach ($m in $machines) { if ($HealthStatus -and [string] $m.healthStatus -notin $HealthStatus) { continue } if ($ExposureLevel -and [string] $m.exposureLevel -notin $ExposureLevel) { continue } $entry = $byDevice[[string] $m.id] # $null when the export could not be read at all; 0 when it was read and this device # had no findings. Those are different answers and must not collapse into each other. $severity = { param($name) if (-not $vulnerabilitiesRead) { $null } elseif ($entry -and $entry.Severity.ContainsKey($name)) { $entry.Severity[$name] } else { 0 } } [PSCustomObject]@{ PSTypeName = 'MsecDefenderDevice' DeviceName = $m.computerDnsName ExposureLevel = $m.exposureLevel RiskScore = $m.riskScore VulnerabilityCount = if (-not $vulnerabilitiesRead) { $null } elseif ($entry) { $entry.Cves.Count } else { 0 } CriticalCount = & $severity 'Critical' HighCount = & $severity 'High' MediumCount = & $severity 'Medium' LowCount = & $severity 'Low' # Rows in the export: the same CVE in three installed products is three of these # and one vulnerability. The gap is the remediation workload. FindingCount = if (-not $vulnerabilitiesRead) { $null } elseif ($entry) { $entry.Findings } else { 0 } OsPlatform = $m.osPlatform OsVersion = $m.version OsBuild = $m.osBuild HealthStatus = $m.healthStatus OnboardingStatus = $m.onboardingStatus LastSeen = & $toUtc $m.lastSeen FirstSeen = & $toUtc $m.firstSeen RbacGroupName = $m.rbacGroupName MachineTags = @($m.machineTags | Where-Object { $_ }) AadDeviceId = $m.aadDeviceId LastIpAddress = $m.lastIpAddress LastExternalIpAddress = $m.lastExternalIpAddress Id = $m.id Raw = $m } } } |