tests/UnipharCanaryResponse.Tests.ps1
|
<#
.SYNOPSIS Pester tests for the Thinkst Canary phishing response helper functions in UnipharSecurityAuth. .DESCRIPTION Covers the pure helper functions (ConvertFrom-CanaryIncident, ConvertTo-CanaryCidr, Test-IpInCidrRange, Test-IpAllowListed) and the REST/Graph-dependent functions via mocks (Confirm-CanaryConnectivity, Get-CanaryIncident, Add-IpToRestrictedNamedLocation, Revoke-UserAccess), including dry-run (-WhatIf) gating. Consumed by the Invoke-CanaryPhishingResponse runbook. .NOTES Author: Security Team Requires: Pester v5 #> BeforeAll { $modulePath = Join-Path (Split-Path $PSScriptRoot -Parent) 'UnipharSecurityAuth.psm1' Import-Module $modulePath -Force # Stub the Microsoft Graph cmdlets (global scope) so Mock -ModuleName can reliably resolve # them regardless of Pester version, without the Graph SDK installed. function global:Get-MgIdentityConditionalAccessNamedLocation { param($All) } function global:Update-MgIdentityConditionalAccessNamedLocation { param($NamedLocationId, $BodyParameter) } function global:Get-MgAuditLogSignIn { param($Filter, $All) } function global:Revoke-MgUserSignInSession { param($UserId) } function global:Invoke-MgGraphRequest { param($Method, $Uri, $Body) } } AfterAll { Remove-Item -Path 'function:global:Get-MgIdentityConditionalAccessNamedLocation' -ErrorAction SilentlyContinue Remove-Item -Path 'function:global:Update-MgIdentityConditionalAccessNamedLocation' -ErrorAction SilentlyContinue Remove-Item -Path 'function:global:Get-MgAuditLogSignIn' -ErrorAction SilentlyContinue Remove-Item -Path 'function:global:Revoke-MgUserSignInSession' -ErrorAction SilentlyContinue Remove-Item -Path 'function:global:Invoke-MgGraphRequest' -ErrorAction SilentlyContinue Remove-Module UnipharSecurityAuth -Force -ErrorAction SilentlyContinue } Describe 'ConvertTo-CanaryCidr' -Tags 'Unit' { It 'Normalizes bare IPv4 "<Value>" to "<Expected>"' -ForEach @( @{ Value = '203.0.113.5'; Expected = '203.0.113.5/32' } @{ Value = ' 198.51.100.10 '; Expected = '198.51.100.10/32' } ) { ConvertTo-CanaryCidr -IpAddress $Value | Should -BeExactly $Expected } It 'Normalizes bare IPv6 to /128' { ConvertTo-CanaryCidr -IpAddress '2001:db8::1' | Should -BeExactly '2001:db8::1/128' } It 'Leaves an existing CIDR unchanged' { ConvertTo-CanaryCidr -IpAddress '203.0.113.0/24' | Should -BeExactly '203.0.113.0/24' } It 'Returns $null for invalid input "<Value>"' -ForEach @( @{ Value = 'not-an-ip' } @{ Value = ' ' } @{ Value = '999.999.999.999' } ) { ConvertTo-CanaryCidr -IpAddress $Value | Should -BeNullOrEmpty } } Describe 'Test-IpInCidrRange' -Tags 'Unit' { It 'Returns $true when "<Ip>" is inside "<Cidr>"' -ForEach @( @{ Ip = '203.0.113.5'; Cidr = '203.0.113.0/24' } @{ Ip = '203.0.113.5'; Cidr = '203.0.113.5/32' } @{ Ip = '10.1.2.3'; Cidr = '10.0.0.0/8' } @{ Ip = '192.168.1.130'; Cidr = '192.168.1.128/25' } @{ Ip = '2001:db8::5'; Cidr = '2001:db8::/32' } ) { Test-IpInCidrRange -IpAddress $Ip -Cidr $Cidr | Should -BeTrue } It 'Returns $false when "<Ip>" is outside "<Cidr>"' -ForEach @( @{ Ip = '203.0.114.5'; Cidr = '203.0.113.0/24' } @{ Ip = '192.168.1.127'; Cidr = '192.168.1.128/25' } @{ Ip = '11.1.2.3'; Cidr = '10.0.0.0/8' } @{ Ip = '2001:dba::5'; Cidr = '2001:db8::/32' } ) { Test-IpInCidrRange -IpAddress $Ip -Cidr $Cidr | Should -BeFalse } It 'Returns $false on address-family mismatch' { Test-IpInCidrRange -IpAddress '203.0.113.5' -Cidr '2001:db8::/32' | Should -BeFalse } It 'Returns $false (never throws) for malformed input' { Test-IpInCidrRange -IpAddress 'garbage' -Cidr 'also-garbage' | Should -BeFalse } } Describe 'Test-IpAllowListed' -Tags 'Unit' { It 'Returns $true when the IP matches an allow-list range' { Test-IpAllowListed -IpAddress '203.0.113.5' -AllowListRange @('10.0.0.0/8', '203.0.113.0/24') | Should -BeTrue } It 'Returns $false when the IP matches no range' { Test-IpAllowListed -IpAddress '198.51.100.7' -AllowListRange @('10.0.0.0/8', '203.0.113.0/24') | Should -BeFalse } It 'Returns $false for an empty or null allow-list' { Test-IpAllowListed -IpAddress '203.0.113.5' -AllowListRange @() | Should -BeFalse Test-IpAllowListed -IpAddress '203.0.113.5' -AllowListRange $null | Should -BeFalse } } Describe 'ConvertFrom-CanaryIncident' -Tags 'Unit' { BeforeAll { $entraTokenId = 'entra-token-abc' $entraIncident = [PSCustomObject]@{ hash_id = 'incident-1' description = [PSCustomObject]@{ canarytoken = 'entra-token-abc' flock_name = 'Citywest' description = 'Azure Entra ID Login' created_std = '2026-07-30 10:00:00 UTC' src_host = '203.0.113.5' events = @( [PSCustomObject]@{ src_host = '203.0.113.5' } [PSCustomObject]@{ src_host = '198.51.100.7' } ) } } $otherTokenIncident = [PSCustomObject]@{ hash_id = 'incident-2' description = [PSCustomObject]@{ canarytoken = 'some-other-token' src_host = '203.0.113.9' events = @() } } $nonTokenIncident = [PSCustomObject]@{ hash_id = 'incident-3' description = [PSCustomObject]@{ logtype = '4002' src_host = '203.0.113.11' } } } It 'Parses an Entra ID Login token incident and extracts unique source IPs' { $result = $entraIncident | ConvertFrom-CanaryIncident -EntraTokenId $entraTokenId $result | Should -Not -BeNullOrEmpty $result.IncidentId | Should -BeExactly 'incident-1' $result.FlockName | Should -BeExactly 'Citywest' $result.SourceIps | Should -HaveCount 2 $result.SourceIps | Should -Contain '203.0.113.5' $result.SourceIps | Should -Contain '198.51.100.7' } It 'Ignores incidents belonging to a different canarytoken' { $otherTokenIncident | ConvertFrom-CanaryIncident -EntraTokenId $entraTokenId | Should -BeNullOrEmpty } It 'Ignores non-token incidents (no canarytoken field)' { $nonTokenIncident | ConvertFrom-CanaryIncident -EntraTokenId $entraTokenId | Should -BeNullOrEmpty } It 'Processes only the matching incident from a mixed batch' { $batch = @($entraIncident, $otherTokenIncident, $nonTokenIncident) $results = $batch | ConvertFrom-CanaryIncident -EntraTokenId $entraTokenId @($results) | Should -HaveCount 1 $results.IncidentId | Should -BeExactly 'incident-1' } } Describe 'Confirm-CanaryConnectivity' -Tags 'Unit' { It 'Returns $true when the API result is success' { Mock -ModuleName UnipharSecurityAuth Invoke-RestMethod { @{ result = 'success' } } Confirm-CanaryConnectivity -ConsoleDomain 'abc.canary.tools' -AuthToken 'tok' | Should -BeTrue } It 'Returns $false when the API result is not success' { Mock -ModuleName UnipharSecurityAuth Invoke-RestMethod { @{ result = 'failure' } } Confirm-CanaryConnectivity -ConsoleDomain 'abc.canary.tools' -AuthToken 'tok' | Should -BeFalse } } Describe 'Get-CanaryIncident' -Tags 'Unit' { It 'Passes the canarytoken filter to the API and returns the incidents array' { Mock -ModuleName UnipharSecurityAuth Invoke-RestMethod { $script:capturedBody = $Body @{ incidents = @([PSCustomObject]@{ hash_id = 'x' }) } } $result = Get-CanaryIncident -ConsoleDomain 'abc.canary.tools' -AuthToken 'tok' -CanaryTokenId 'entra-token-abc' @($result) | Should -HaveCount 1 Should -Invoke -ModuleName UnipharSecurityAuth Invoke-RestMethod -Times 1 -ParameterFilter { $Headers['X-Canary-Auth-Token'] -eq 'tok' -and $Body['canarytoken'] -eq 'entra-token-abc' } } } Describe 'Add-IpToRestrictedNamedLocation' -Tags 'Unit' { BeforeEach { $script:namedLocation = [PSCustomObject]@{ Id = 'loc-1' DisplayName = 'Restriced IPs' AdditionalProperties = @{ '@odata.type' = '#microsoft.graph.ipNamedLocation' isTrusted = $false ipRanges = @( @{ '@odata.type' = '#microsoft.graph.iPv4CidrRange'; cidrAddress = '203.0.113.0/24' } ) } } Mock -ModuleName UnipharSecurityAuth Get-MgIdentityConditionalAccessNamedLocation { $script:namedLocation } Mock -ModuleName UnipharSecurityAuth Update-MgIdentityConditionalAccessNamedLocation { } } It 'Adds a new IP and updates the named location' { $added = Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '198.51.100.7' $added | Should -Contain '198.51.100.7/32' Should -Invoke -ModuleName UnipharSecurityAuth Update-MgIdentityConditionalAccessNamedLocation -Times 1 } It 'Does not update when the IP range already exists (de-duplication)' { $added = Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '203.0.113.0/24' $added | Should -BeNullOrEmpty Should -Invoke -ModuleName UnipharSecurityAuth Update-MgIdentityConditionalAccessNamedLocation -Times 0 } It 'Does NOT update the named location under -WhatIf (dry run)' { Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '198.51.100.7' -WhatIf Should -Invoke -ModuleName UnipharSecurityAuth Update-MgIdentityConditionalAccessNamedLocation -Times 0 } It 'Throws when the named location does not exist' { Mock -ModuleName UnipharSecurityAuth Get-MgIdentityConditionalAccessNamedLocation { @() } { Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Missing' -IpAddress '198.51.100.7' } | Should -Throw '*not found*' } It 'Throws when adding would exceed the MaxRanges ceiling' { { Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '198.51.100.7' -MaxRanges 1 } | Should -Throw '*exceeding the 1 ceiling*' } It 'Throws (fails closed) when the named location has no isTrusted value' { [void]$script:namedLocation.AdditionalProperties.Remove('isTrusted') { Add-IpToRestrictedNamedLocation -NamedLocationDisplayName 'Restriced IPs' -IpAddress '198.51.100.7' } | Should -Throw '*no isTrusted value*' } } Describe 'Revoke-UserAccess' -Tags 'Unit' { BeforeEach { Mock -ModuleName UnipharSecurityAuth Revoke-MgUserSignInSession { } Mock -ModuleName UnipharSecurityAuth Invoke-MgGraphRequest { } } It 'Revokes sessions and invalidates refresh tokens' { Revoke-UserAccess -UserId 'user-1' Should -Invoke -ModuleName UnipharSecurityAuth Revoke-MgUserSignInSession -Times 1 Should -Invoke -ModuleName UnipharSecurityAuth Invoke-MgGraphRequest -Times 1 -ParameterFilter { $Uri -like '*invalidateAllRefreshTokens*' } } It 'Takes no action under -WhatIf (dry run)' { Revoke-UserAccess -UserId 'user-1' -WhatIf Should -Invoke -ModuleName UnipharSecurityAuth Revoke-MgUserSignInSession -Times 0 Should -Invoke -ModuleName UnipharSecurityAuth Invoke-MgGraphRequest -Times 0 } } Describe 'Get-SignInUserByIp' -Tags 'Unit' { It 'Throws on a non-IP value rather than building an OData filter' { { Get-SignInUserByIp -IpAddress "1.2.3.4' or startsWith(userPrincipalName,'admin" } | Should -Throw '*not a valid IP address*' } } |