tests/Get-MsecDefenderDevice.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecDefenderDevice. # # The behaviour worth pinning down is the counting, because the obvious implementation is # wrong in two directions: # # * the assessment export is one row per (device, software, CVE), so the same CVE affecting # three installed versions of a product is THREE rows and ONE vulnerability. Counting rows # inflates every device by a factor that varies with how much software it has. # * severity must therefore be counted once per CVE too, or the severity columns stop # adding up to VulnerabilityCount. # # And the one that matters most: a tenant without Defender Vulnerability Management answers # 403 on the export. Reporting 0 there would read as "no device has any vulnerability" - the # most dangerous wrong answer this command can give - so the counts must be $null and the # device rows must still come back. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop $script:TestThumbBytes = [byte[]](1..20) } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecDefenderDevice' { BeforeEach { InModuleScope Msec -Parameters @{ Thumb = $script:TestThumbBytes } { param($Thumb) $script:MsecSession = @{ TenantId = 'tenant'; ClientId = 'client'; KeyVaultName = 'kv-test' KeyName = 'msec-app'; ThumbprintBytes = $Thumb; Tokens = @{} Endpoints = @{ DefenderResource = 'https://api.securitycenter.microsoft.com' EnvironmentName = 'AzureCloud' } } } } It 'counts distinct CVEs, not export rows' { $rows = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; exposureLevel = 'High'; healthStatus = 'Active' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { # ONE CVE, three installed versions of the same product - three rows. [pscustomobject]@{ deviceId = 'd1'; cveId = 'CVE-2026-1111'; vulnerabilitySeverityLevel = 'Critical'; softwareName = 'openssl'; softwareVersion = '1.0' } [pscustomobject]@{ deviceId = 'd1'; cveId = 'CVE-2026-1111'; vulnerabilitySeverityLevel = 'Critical'; softwareName = 'openssl'; softwareVersion = '1.1' } [pscustomobject]@{ deviceId = 'd1'; cveId = 'CVE-2026-1111'; vulnerabilitySeverityLevel = 'Critical'; softwareName = 'openssl'; softwareVersion = '3.0' } # A genuinely different one. [pscustomobject]@{ deviceId = 'd1'; cveId = 'CVE-2026-2222'; vulnerabilitySeverityLevel = 'High'; softwareName = 'curl'; softwareVersion = '8.0' } } Get-MsecDefenderDevice } # Two vulnerabilities, four findings. Not four vulnerabilities. $rows.VulnerabilityCount | Should -Be 2 $rows.FindingCount | Should -Be 4 # Severity counted once per CVE too, or these stop summing to VulnerabilityCount. $rows.CriticalCount | Should -Be 1 $rows.HighCount | Should -Be 1 ($rows.CriticalCount + $rows.HighCount + $rows.MediumCount + $rows.LowCount) | Should -Be $rows.VulnerabilityCount } It 'attributes findings to the right device and leaves clean ones at zero' { $rows = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; healthStatus = 'Active' } [pscustomobject]@{ id = 'd2'; computerDnsName = 'db01'; healthStatus = 'Active' } [pscustomobject]@{ id = 'd3'; computerDnsName = 'kiosk'; healthStatus = 'Inactive' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { [pscustomobject]@{ deviceId = 'd1'; cveId = 'CVE-1'; vulnerabilitySeverityLevel = 'Critical' } [pscustomobject]@{ deviceId = 'd1'; cveId = 'CVE-2'; vulnerabilitySeverityLevel = 'Low' } [pscustomobject]@{ deviceId = 'd2'; cveId = 'CVE-1'; vulnerabilitySeverityLevel = 'Critical' } } Get-MsecDefenderDevice } $byName = @{}; foreach ($r in $rows) { $byName[$r.DeviceName] = $r } $byName['web01'].VulnerabilityCount | Should -Be 2 $byName['db01'].VulnerabilityCount | Should -Be 1 # The same CVE on two devices is one vulnerability EACH, not shared. $byName['db01'].CriticalCount | Should -Be 1 # Absent from the export means no findings recorded - a real 0, not a null. $byName['kiosk'].VulnerabilityCount | Should -Be 0 $byName['kiosk'].CriticalCount | Should -Be 0 # ...but it is Inactive, which is what tells the reader nobody has looked recently. # The command reports both rather than deciding for them. $byName['kiosk'].HealthStatus | Should -Be 'Inactive' } It 'reads the id and severity under both names Defender uses' { # The bug this exists for: the command called /api/vulnerabilities/machinesVulnerabilities # - which returns machineId and severity - while looking for deviceId and # vulnerabilitySeverityLevel, the names the OTHER bulk endpoint uses. Every row was # discarded for having no id, and against a live tenant the whole estate reported 0 # vulnerabilities with no error at all. $withMachineId = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; healthStatus = 'Active' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { # The shape /api/vulnerabilities/machinesVulnerabilities actually returns. [pscustomobject]@{ machineId = 'd1'; cveId = 'CVE-1'; severity = 'Critical'; productName = 'openssl' } [pscustomobject]@{ machineId = 'd1'; cveId = 'CVE-2'; severity = 'High'; productName = 'curl' } } Get-MsecDefenderDevice } $withMachineId.VulnerabilityCount | Should -Be 2 $withMachineId.CriticalCount | Should -Be 1 $withMachineId.HighCount | Should -Be 1 $withDeviceId = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; healthStatus = 'Active' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { # The shape the export endpoint returns, accepted so swapping endpoint cannot # reintroduce the silent zero. [pscustomobject]@{ deviceId = 'd1'; cveId = 'CVE-1'; vulnerabilitySeverityLevel = 'Critical' } [pscustomobject]@{ deviceId = 'd1'; cveId = 'CVE-2'; vulnerabilitySeverityLevel = 'High' } } Get-MsecDefenderDevice } $withDeviceId.VulnerabilityCount | Should -Be 2 $withDeviceId.CriticalCount | Should -Be 1 } It 'refuses to report zero when rows came back it could not attribute' { $warnings = @() $rows = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; healthStatus = 'Active' } [pscustomobject]@{ id = 'd2'; computerDnsName = 'db01'; healthStatus = 'Active' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { # Findings arrive, but under a key nothing knows about - a schema this code # does not understand. Indistinguishable from a clean estate in the output # unless it is caught here. [pscustomobject]@{ someUnknownIdField = 'd1'; cveId = 'CVE-1'; severity = 'Critical' } [pscustomobject]@{ someUnknownIdField = 'd1'; cveId = 'CVE-2'; severity = 'High' } } Get-MsecDefenderDevice } -WarningVariable warnings -WarningAction SilentlyContinue # Null, not 0. A row of zeroes across the estate reads as good news, and this is the # opposite of news. foreach ($r in $rows) { $r.VulnerabilityCount | Should -BeNullOrEmpty $r.CriticalCount | Should -BeNullOrEmpty } ($warnings -join ' ') | Should -Match 'not in the expected shape' ($warnings -join ' ') | Should -Match '2 row' } It 'still reports zero when the export genuinely returns nothing' { # The other side of the guard: an empty export is a real answer, and must not be # turned into nulls by the shape check. $warnings = @() $rows = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; healthStatus = 'Active' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { @() } Get-MsecDefenderDevice } -WarningVariable warnings -WarningAction SilentlyContinue $rows.VulnerabilityCount | Should -Be 0 $rows.CriticalCount | Should -Be 0 ($warnings -join ' ') | Should -Not -Match 'not in the expected shape' } It 'reports null counts, not zero, when the vulnerability export cannot be read' { $rows = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; exposureLevel = 'High'; healthStatus = 'Active' } [pscustomobject]@{ id = 'd2'; computerDnsName = 'db01'; exposureLevel = 'Low'; healthStatus = 'Active' } } # No Defender Vulnerability Management licence. Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } Get-MsecDefenderDevice -WarningAction SilentlyContinue } # The inventory survives - losing the counts must not lose the devices. @($rows).Count | Should -Be 2 # And every count is null. A 0 here would read as "no device has any vulnerability", # which is the one conclusion a failed read must never support. foreach ($r in $rows) { $r.VulnerabilityCount | Should -BeNullOrEmpty $r.CriticalCount | Should -BeNullOrEmpty $r.FindingCount | Should -BeNullOrEmpty $r.VulnerabilityCount | Should -Not -Be 0 } # What the API did answer is still there. ($rows | Where-Object DeviceName -eq 'web01').ExposureLevel | Should -Be 'High' } It 'warns and names the permission when the export is forbidden' { $warnings = @() InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } Get-MsecDefenderDevice } -WarningVariable warnings -WarningAction SilentlyContinue | Out-Null ($warnings -join ' ') | Should -Match 'Vulnerability\.Read\.All' ($warnings -join ' ') | Should -Match 'null rather than 0' } It 'normalises timestamps to UTC rather than local' { $row = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; lastSeen = '2026-09-01T08:00:00Z' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { @() } Get-MsecDefenderDevice } # A plain [datetime] cast returns Kind=Local - the right instant in local wall-clock - # which then compares wrongly against a UTC clock. $row.LastSeen.Kind | Should -Be 'Utc' $row.LastSeen | Should -Be ([datetime]::new(2026, 9, 1, 8, 0, 0, [DateTimeKind]::Utc)) } It 'filters on health and exposure without a second fetch' { $rows = InModuleScope Msec { Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -eq '/api/machines' } -MockWith { [pscustomobject]@{ id = 'd1'; computerDnsName = 'web01'; healthStatus = 'Active'; exposureLevel = 'High' } [pscustomobject]@{ id = 'd2'; computerDnsName = 'db01'; healthStatus = 'Active'; exposureLevel = 'Low' } [pscustomobject]@{ id = 'd3'; computerDnsName = 'old'; healthStatus = 'Inactive'; exposureLevel = 'High' } } Mock Invoke-MsecDefenderRequest -ParameterFilter { $Path -match 'machinesVulnerabilities' } -MockWith { @() } Get-MsecDefenderDevice -HealthStatus Active -ExposureLevel High } @($rows).Count | Should -Be 1 $rows.DeviceName | Should -Be 'web01' } It 'names the missing permission when the device list itself is forbidden' { InModuleScope Msec { Mock Invoke-MsecDefenderRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } { Get-MsecDefenderDevice } | Should -Throw '*Machine.Read.All*' } } It 'throws a clear error when not connected' { InModuleScope Msec { $script:MsecSession = $null { Get-MsecDefenderDevice } | Should -Throw '*Connect-Msec*' } } } |