tests/Get-MsecDefenderDetectionRule.Tests.ps1
|
#Requires -Module Pester # # Tests for Get-MsecDefenderDetectionRule. # # Each covers a way this can silently mislead: # - isEnabled was REMOVED from the resource on 2026-10-01. Reading it gets $null, which is # falsy, and reports every live rule as disabled. 'status' is the replacement. # - 'autoDisabled' is a state isEnabled could never express: Defender switched the rule off # because its query broke. It still appears in the portal and is not running. # - An empty list is not "we have no detections" - built-in analytics and Sentinel rules are # separate stores - so it warns rather than returning silence. # - The 403 must say why ThreatHunting.Read.All is not enough, since that is the permission # anyone would assume covers this. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Get-MsecDefenderDetectionRule' { BeforeEach { InModuleScope Msec { Mock Assert-MsecSession -MockWith { } } } It 'projects a rule from status, schedule and alert template' { $row = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { @([pscustomobject]@{ id = 'r1'; displayName = 'Anthropic cert rotation' description = 'New signing cert needs an indicator' status = 'enabled' createdBy = 'me@contoso.com'; createdDateTime = '2026-10-08T09:00:00Z' lastModifiedBy = 'me@contoso.com'; lastModifiedDateTime = '2026-10-08T09:00:00Z' queryCondition = [pscustomobject]@{ queryText = 'DeviceFileCertificateInfo | where Signer has "Anthropic"' } schedule = [pscustomobject]@{ frequency = 'PT24H'; nextRunDateTime = '2026-10-09T09:00:00Z' } detectionAction = [pscustomobject]@{ alertTemplate = [pscustomobject]@{ title = 'Unapproved Anthropic signing certificate'; severity = 'informational'; category = 'Discovery' } } }) } Get-MsecDefenderDetectionRule } $row.DisplayName | Should -Be 'Anthropic cert rotation' $row.Status | Should -Be 'enabled' $row.IsRunning | Should -BeTrue $row.Frequency | Should -Be 'PT24H' $row.NextRun | Should -BeOfType [datetime] $row.Severity | Should -Be 'informational' $row.Query | Should -Match 'DeviceFileCertificateInfo' } It 'surfaces autoDisabled and warns, because such a rule looks live and is not running' { $out = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { @( [pscustomobject]@{ id='ok'; displayName='Working'; status='enabled' queryCondition=[pscustomobject]@{ queryText='DeviceEvents' } schedule=[pscustomobject]@{}; detectionAction=[pscustomobject]@{} } [pscustomobject]@{ id='dead'; displayName='Broken query'; status='autoDisabled' queryCondition=[pscustomobject]@{ queryText='DeviceEvents | where RenamedColumn == 1' } schedule=[pscustomobject]@{}; detectionAction=[pscustomobject]@{} } ) } $w = @() $r = @(Get-MsecDefenderDetectionRule -WarningVariable w -WarningAction SilentlyContinue) [pscustomobject]@{ Rows = $r; Warnings = "$($w -join ' ')" } } $dead = $out.Rows | Where-Object Id -eq 'dead' $dead.Status | Should -Be 'autoDisabled' # Not running, but also not 'disabled' - nobody chose this. $dead.IsRunning | Should -BeFalse $out.Warnings | Should -Match 'AUTODISABLED' $out.Warnings | Should -Match 'still appear in the portal' } It 'does not read the retired isEnabled property as authoritative' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { @( # Current shape: status present, isEnabled gone. A reader still keyed on # isEnabled would get $null and call this disabled. [pscustomobject]@{ id='new'; displayName='Current'; status='enabled' queryCondition=[pscustomobject]@{}; schedule=[pscustomobject]@{} detectionAction=[pscustomobject]@{} } # Legacy shape, for a tenant still returning the old property. [pscustomobject]@{ id='old'; displayName='Legacy'; isEnabled=$true queryCondition=[pscustomobject]@{}; schedule=[pscustomobject]@{} detectionAction=[pscustomobject]@{} } # Neither: unknown, which must not be reported as off. [pscustomobject]@{ id='unk'; displayName='Unknown' queryCondition=[pscustomobject]@{}; schedule=[pscustomobject]@{} detectionAction=[pscustomobject]@{} } ) } , @(Get-MsecDefenderDetectionRule) } ($rows | Where-Object Id -eq 'new').IsRunning | Should -BeTrue ($rows | Where-Object Id -eq 'old').Status | Should -Be 'enabled' ($rows | Where-Object Id -eq 'unk').Status | Should -BeNullOrEmpty ($rows | Where-Object Id -eq 'unk').IsRunning | Should -BeNullOrEmpty } It 'filters on the query text, so "is anything watching this" is one call' { $rows = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { @( [pscustomobject]@{ id='asr'; displayName='ASR blocks'; status='enabled' queryCondition=[pscustomobject]@{ queryText='DeviceEvents | where ActionType startswith "Asr"' } schedule=[pscustomobject]@{}; detectionAction=[pscustomobject]@{} } [pscustomobject]@{ id='mail'; displayName='Phish'; status='enabled' queryCondition=[pscustomobject]@{ queryText='EmailEvents | where ThreatTypes has "Phish"' } schedule=[pscustomobject]@{}; detectionAction=[pscustomobject]@{} } ) } , @(Get-MsecDefenderDetectionRule -Query 'Asr') } @($rows | ForEach-Object Id) | Should -Be @('asr') } It 'warns on an empty list rather than implying there are no detections anywhere' { $warning = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { @() } $w = @() Get-MsecDefenderDetectionRule -WarningVariable w -WarningAction SilentlyContinue | Out-Null "$($w -join ' ')" } $warning | Should -Match 'NOT the same as having no detections' # The neighbouring store, named so the reader does not conclude too much. $warning | Should -Match 'Get-MsecSentinelRule' } It 'explains why ThreatHunting.Read.All does not cover this' { $err = InModuleScope Msec { Mock Invoke-MsecGraphRequest -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } try { Get-MsecDefenderDetectionRule; $null } catch { "$($_.Exception.Message)" } } $err | Should -Match 'CustomDetection\.Read\.All' $err | Should -Match 'ThreatHunting\.Read\.All' $err | Should -Match 'New-MsecApp' } } |