tests/Set-MsecDefenderIncident.Tests.ps1
|
#Requires -Module Pester # # Tests for Set-MsecDefenderIncident. The guards are the same family as Set-MsecDefenderAlert's, # plus two that are specific to incidents: # # -CustomTags REPLACES the tag array. Graph has no append for a collection property, so the # command must say so before dropping existing tags rather than after. # # 'redirected' must not be settable. Defender assigns it when it merges an incident into # another; offering it as a status would imply this command can merge incidents, which it # cannot. # # And the reason this command exists at all: the resolution comment lives on the incident, # because Graph has no writable comment on an alert. BeforeAll { $script:StubbedGraphCommands = @() Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop # Microsoft.Graph.Authentication is NOT a dependency of msec - only Connect-MsecAdmin needs # it, and it checks at run time. It is preinstalled on the Windows and Ubuntu GitHub images # but NOT on the macOS one, so these tests passed on two runners and failed on the third: # Pester's Mock requires the command to EXIST, and a missing one fails as "Could not find # Command Get-MgContext" rather than as anything pointing at the real cause. # # Stubbed when absent OR unusable, so a machine with a working module still mocks the # real command and the two behave identically. # The stubs carry the REAL parameter names, not an empty param(). A -ParameterFilter is # evaluated against the mocked command's own signature, so a stub without -TenantId leaves # $TenantId unbound and the filter silently matches nothing - the mock is invoked, the # assertion counts zero calls, and the failure looks like the command was never called. $graphStubs = @{ 'Get-MgContext' = { [CmdletBinding()] param() } 'Disconnect-MgGraph' = { [CmdletBinding()] param() } 'Connect-MgGraph' = { [CmdletBinding()] param( [string[]] $Scopes, [string] $TenantId, [switch] $NoWelcome, $AccessToken, [string] $Environment ) } } foreach ($graphCmd in $graphStubs.Keys) { # DISCOVERABLE IS NOT USABLE, and testing only for presence is what broke CI on the # ubuntu runner: Get-MgContext resolved as a command while its backing assembly # (Microsoft.Graph.Authentication.Core) could not be loaded, so the stub was skipped # and Pester's Mock then died building CommandMetadata - # "Cannot convert value Get-MgContext to type CommandMetadata" # which points at the mock rather than at the half-installed module behind it. So the # probe is the same one Mock performs: build the metadata, and stub when that fails. # A global function shadows a cmdlet of the same name, so the stub wins either way. $usable = $false $found = Get-Command $graphCmd -ErrorAction SilentlyContinue if ($found) { try { $null = [System.Management.Automation.CommandMetadata]::new($found); $usable = $true } catch { $usable = $false } } if (-not $usable) { Set-Item "function:global:$graphCmd" -Value $graphStubs[$graphCmd] -Force $script:StubbedGraphCommands += $graphCmd } } function New-AdminSession { [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } } } AfterAll { foreach ($graphCmd in $script:StubbedGraphCommands) { Remove-Item "function:global:$graphCmd" -ErrorAction SilentlyContinue } Remove-Module msec -Force -ErrorAction SilentlyContinue } Describe 'Set-MsecDefenderIncident' { It 'refuses when only the read-only app session exists' { InModuleScope msec { $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} } $script:MsecAdminSession = $null { Set-MsecDefenderIncident -Id '1' -Status resolved -Confirm:$false } | Should -Throw '*Connect-MsecAdmin*' } } It 'writes every incident piped in - there is no cap' { InModuleScope msec { $script:MsecAdminSession = [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } } Mock Start-Sleep -MockWith { } Mock Invoke-MsecAdminGraphRequest -MockWith { @{ id = '1'; status = 'resolved' } } $rows = @(1..40 | Set-MsecDefenderIncident -Status resolved -Confirm:$false) $rows.Count | Should -Be 40 } } It 'sends the resolving comment as resolvingComment' { $sent = InModuleScope msec { $script:MsecAdminSession = [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } } Mock Start-Sleep -MockWith { } $script:captured = $null Mock Invoke-MsecAdminGraphRequest -MockWith { if ($Method -eq 'PATCH') { $script:captured = $Body } @{ id = '1'; displayName = 'D'; severity = 'low'; status = 'resolved' resolvingComment = 'Authorised pen test' } } $null = '1' | Set-MsecDefenderIncident -Status resolved ` -ResolvingComment 'Authorised pen test' -Confirm:$false $script:captured } $sent['resolvingComment'] | Should -Be 'Authorised pen test' $sent['status'] | Should -Be 'resolved' } It 'reports the comment that came back on re-read' { $row = InModuleScope msec { $script:MsecAdminSession = [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } } Mock Start-Sleep -MockWith { } $script:n = 0 Mock Invoke-MsecAdminGraphRequest -MockWith { $script:n++ if ($script:n -eq 1) { @{ id = '1'; displayName = 'D'; severity = 'high'; status = 'active' } } else { @{ id = '1'; displayName = 'D'; severity = 'high'; status = 'resolved' resolvingComment = 'Closed - benign' } } } '1' | Set-MsecDefenderIncident -Status resolved -ResolvingComment 'Closed - benign' -Confirm:$false } $row.StatusBefore | Should -Be 'active' $row.StatusAfter | Should -Be 'resolved' $row.ResolvingCommentAfter | Should -Be 'Closed - benign' $row.Changed | Should -BeTrue } It 'warns before -CustomTags drops the tags already on the incident' { $warnings = InModuleScope msec { $script:MsecAdminSession = [PSCustomObject]@{ Account = 'me@contoso.com'; TenantId = 't' GrantedScope = @('SecurityIncident.ReadWrite.All') } Mock Get-MgContext -MockWith { [PSCustomObject]@{ Account = 'me'; TenantId = 't' } } Mock Start-Sleep -MockWith { } Mock Invoke-MsecAdminGraphRequest -MockWith { @{ id = '1'; displayName = 'D'; severity = 'low'; status = 'active' customTags = @('KeepMe', 'AndMe') } } $null = '1' | Set-MsecDefenderIncident -CustomTags 'Replacement' -Confirm:$false ` -WarningVariable w -WarningAction SilentlyContinue $w } ($warnings -join ' ') | Should -Match 'replaces rather than appends' ($warnings -join ' ') | Should -Match 'KeepMe' } It 'does not offer redirected as a settable status' { $values = (Get-Command Set-MsecDefenderIncident).Parameters['Status'].Attributes. Where({ $_ -is [ValidateSet] }).ValidValues $values | Should -Not -Contain 'redirected' $values | Should -Contain 'resolved' $values | Should -Contain 'active' } It 'offers the statuses that are in $metadata but missing from the docs' { $values = (Get-Command Set-MsecDefenderIncident).Parameters['Status'].Attributes. Where({ $_ -is [ValidateSet] }).ValidValues $values | Should -Contain 'inProgress' $values | Should -Contain 'awaitingAction' } It 'is the only place a note can be written - alerts have no comment at all' { # Graph has no writable comment on an alert. Set-MsecDefenderAlert briefly had a -Comment # that routed to the Defender for Endpoint API, which covered 29 of 569 alerts on the # measured tenant and put a second user identity inside one command. It was removed, so # resolvingComment on the incident is now the single route for a resolution note. (Get-Command Set-MsecDefenderIncident).Parameters.Keys | Should -Contain 'ResolvingComment' (Get-Command Set-MsecDefenderAlert).Parameters.Keys | Should -Not -Contain 'Comment' (Get-Command Set-MsecDefenderAlert).Parameters.Keys | Should -Not -Contain 'ResolvingComment' } } |