tests/New-MsecDefenderIndicator.Tests.ps1
|
#Requires -Module Pester # # Tests for New-MsecDefenderIndicator. It POSTs to the Defender indicator API on a DELEGATED # token from the Az context, because that API has no read-only scope and the msec app is # deliberately read-only. # # Covered: # - A malformed certificate thumbprint is rejected BEFORE the call. The API accepts one # without complaint and the indicator then matches nothing, which is indistinguishable # from a working indicator the product is ignoring. # - An existing identical indicator is reported, not duplicated. # - A failed duplicate check warns and still allows a deliberate creation. # - ShouldProcess: -WhatIf creates nothing. # - A 403 names the USER's missing scope, not the app's. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop } AfterAll { Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'New-MsecDefenderIndicator' { BeforeEach { InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Account = [pscustomobject]@{ Id = 'me@contoso.com' } } } Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = 'delegated-token' } } Mock Get-MsecEnvironment -MockWith { @{ DefenderResource = 'https://api.securitycenter.microsoft.com' } } } } It 'rejects a malformed certificate thumbprint before sending anything' { InModuleScope Msec { Mock Invoke-RestMethod -MockWith { throw 'should not be called' } $err = $null try { New-MsecDefenderIndicator -Type CertificateThumbprint -Value 'not-a-thumbprint' ` -Action Allowed -Title 't' -Description 'd' -Confirm:$false } catch { $err = "$($_.Exception.Message)" } $err | Should -Match 'not a SHA-1 certificate thumbprint' # The message has to name where the right value comes from, or the reader is left # hunting for a thumbprint they already have in another command's output. $err | Should -Match 'SignerHash' # Validated before the token is even fetched, so nothing reaches the network. Should -Invoke Invoke-RestMethod -Times 0 -Exactly } } It 'accepts a thumbprint with spaces or colons and normalises it to lower-case hex' { $sent = InModuleScope Msec { $script:body = $null Mock Invoke-RestMethod -ParameterFilter { $Method -ne 'Post' } -MockWith { [pscustomobject]@{ value = @() } } Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { $script:body = $Body | ConvertFrom-Json [pscustomobject]@{ id = '1'; indicatorType = 'CertificateThumbprint'; indicatorValue = $script:body.indicatorValue; action = 'Allowed' } } New-MsecDefenderIndicator -Type CertificateThumbprint ` -Value '0D:75:81:D2:C5:1C:59:DF:68:6C:30:00:C7:0B:F5:43:F9:F6:C6:CB' ` -Action Allowed -Title 't' -Description 'd' -Confirm:$false | Out-Null $script:body } $sent.indicatorValue | Should -Be '0d7581d2c51c59df686c3000c70bf543f9f6c6cb' } It 'reports an existing identical indicator instead of creating a duplicate' { $row = InModuleScope Msec { Mock Invoke-RestMethod -ParameterFilter { $Method -ne 'Post' } -MockWith { [pscustomobject]@{ value = @( [pscustomobject]@{ id = '42'; indicatorType = 'CertificateThumbprint' indicatorValue = '0d7581d2c51c59df686c3000c70bf543f9f6c6cb' action = 'Allowed'; title = 'existing'; createdBy = 'someone@contoso.com' } ) } } Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'must not POST' } New-MsecDefenderIndicator -Type CertificateThumbprint ` -Value '0d7581d2c51c59df686c3000c70bf543f9f6c6cb' -Action Allowed ` -Title 't' -Description 'd' -Confirm:$false -WarningAction SilentlyContinue } $row.AlreadyExisted | Should -BeTrue $row.Id | Should -Be '42' InModuleScope Msec { Should -Invoke Invoke-RestMethod -Times 0 -Exactly -ParameterFilter { $Method -eq 'Post' } } } It 'warns but still creates when the duplicate check itself fails' { $out = InModuleScope Msec { Mock Invoke-RestMethod -ParameterFilter { $Method -ne 'Post' } -MockWith { throw 'transient 500' } Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { [pscustomobject]@{ id = '7'; indicatorType = 'CertificateThumbprint'; indicatorValue = 'a' * 40; action = 'Allowed' } } $w = @() $r = New-MsecDefenderIndicator -Type CertificateThumbprint -Value ('a' * 40) ` -Action Allowed -Title 't' -Description 'd' -Confirm:$false -WarningVariable w -WarningAction SilentlyContinue [pscustomobject]@{ Row = $r; Warnings = "$($w -join ' ')" } } $out.Row.Id | Should -Be '7' # Silence here would mean the caller believes a guard ran that did not. $out.Warnings | Should -Match 'duplicate check did NOT run' } It 'creates nothing under -WhatIf' { InModuleScope Msec { Mock Invoke-RestMethod -ParameterFilter { $Method -ne 'Post' } -MockWith { [pscustomobject]@{ value = @() } } Mock Invoke-RestMethod -ParameterFilter { $Method -eq 'Post' } -MockWith { throw 'must not POST under -WhatIf' } New-MsecDefenderIndicator -Type CertificateThumbprint -Value ('b' * 40) ` -Action Allowed -Title 't' -Description 'd' -WhatIf | Out-Null Should -Invoke Invoke-RestMethod -Times 0 -Exactly -ParameterFilter { $Method -eq 'Post' } } } It 'blames the signed-in user for a 403, not the msec app' { $err = InModuleScope Msec { Mock Invoke-RestMethod -MockWith { throw 'Response status code does not indicate success: 403 (Forbidden).' } try { New-MsecDefenderIndicator -Type DomainName -Value 'x.example' -Action Block -Title 't' -Description 'd' -Confirm:$false; $null } catch { "$($_.Exception.Message)" } } $err | Should -Match 'Ti\.ReadWrite' $err | Should -Match 'me@contoso\.com' # The app is deliberately not the identity here, so the message must not send anyone to New-MsecApp. $err | Should -Not -Match 'New-MsecApp' } It 'refuses without an Azure context and says why the app cannot stand in' { $err = InModuleScope Msec { Mock Get-AzContext -MockWith { $null } try { New-MsecDefenderIndicator -Type DomainName -Value 'x.example' -Action Block -Title 't' -Description 'd' -Confirm:$false; $null } catch { "$($_.Exception.Message)" } } $err | Should -Match 'Connect-AzAccount' $err | Should -Match 'no read-only scope' } } |