tests/Search-MsecDefenderHunting.Tests.ps1
|
#Requires -Module Pester # # Tests for Search-MsecDefenderHunting. # # The things worth pinning are the ones that make a hunting result trustworthy rather than # merely present: # # The window goes to the API as its own timespan, so the .kql files carry no ago() - one file # serves every window, and nothing silently intersects with what the caller asked for. # # A BARE INTEGER -Timespan is TICKS. -Timespan 7 means 700 nanoseconds, so the query returns # nothing and reads as "there was nothing to find" - the single most dangerous way for a # security query to be wrong. # # A table belonging to an un-onboarded product FAILS TO RESOLVE rather than returning zero # rows. Translating that into a plain sentence is the point: "0 results" and "this product is # not installed" must never look alike. BeforeAll { Import-Module (Join-Path $PSScriptRoot '..' 'msec.psm1') -Force -ErrorAction Stop } AfterAll { Remove-Module msec -Force -ErrorAction SilentlyContinue } Describe 'Search-MsecDefenderHunting' { BeforeEach { InModuleScope msec { $script:MsecSession = @{ TenantId = 't'; ClientId = 'c'; Tokens = @{} } } } It 'refuses without a session' { InModuleScope msec { $script:MsecSession = $null { Search-MsecDefenderHunting -Subject Alert } | Should -Throw '*Connect-Msec*' } } It 'sends the bundled file and a whole-day ISO window' { $sent = InModuleScope msec { $script:captured = $null Mock Invoke-MsecGraphRequest -MockWith { $script:captured = $Body @{ results = @(); schema = @() } } $null = Search-MsecDefenderHunting -Subject SignIn -Name Failed -Days 3 $script:captured } $sent['Timespan'] | Should -Be 'P3D' $sent['Query'] | Should -Match 'AADSignInEventsBeta' $sent['Query'] | Should -Match 'ErrorCode != 0' } It 'defaults to seven days' { $sent = InModuleScope msec { $script:captured = $null Mock Invoke-MsecGraphRequest -MockWith { $script:captured = $Body; @{ results = @() } } $null = Search-MsecDefenderHunting -Subject Alert $script:captured } $sent['Timespan'] | Should -Be 'P7D' } It 'converts a sub-day -Timespan to hours and minutes' { $sent = InModuleScope msec { $script:captured = $null Mock Invoke-MsecGraphRequest -MockWith { $script:captured = $Body; @{ results = @() } } $null = Search-MsecDefenderHunting -Subject Alert -Timespan 04:30:00 $script:captured } $sent['Timespan'] | Should -Be 'PT4H30M' } It 'refuses a bare-integer -Timespan, which PowerShell reads as ticks' { { Search-MsecDefenderHunting -Subject Alert -Timespan 7 } | Should -Throw '*read as TICKS*' } It 'refuses a window past the stores retention' { { Search-MsecDefenderHunting -Subject Alert -Days 90 } | Should -Throw } It 'runs literal KQL without touching the bundled files' { $sent = InModuleScope msec { $script:captured = $null Mock Invoke-MsecGraphRequest -MockWith { $script:captured = $Body; @{ results = @() } } $null = Search-MsecDefenderHunting -Query 'DeviceInfo | take 1' -Days 1 $script:captured } $sent['Query'] | Should -Be 'DeviceInfo | take 1' } It 'names a missing .kql rather than running something else' { { Search-MsecDefenderHunting -Subject SignIn -Name NoSuchQuery } | Should -Throw '*NoSuchQuery.kql*' } It 'translates an unresolved table into "not onboarded", not zero rows' { InModuleScope msec { Mock Invoke-MsecGraphRequest -MockWith { throw "Failed to resolve table or column expression named 'CloudAppEvents'" } { Search-MsecDefenderHunting -Query 'CloudAppEvents | count' } | Should -Throw '*not onboarded or not licensed*' } } It 'emits one object per result row' { $rows = InModuleScope msec { Mock Invoke-MsecGraphRequest -MockWith { @{ results = @(@{ Severity = 'High'; n = '83' }, @{ Severity = 'Low'; n = '126' }) } } @(Search-MsecDefenderHunting -Query 'AlertInfo | count') } $rows.Count | Should -Be 2 $rows[0].Severity | Should -Be 'High' } } Describe 'kql/Hunting bundled queries' { BeforeAll { $script:HuntRoot = Join-Path (Get-Module msec).ModuleBase 'kql/Hunting' $script:HuntFiles = @(Get-ChildItem -LiteralPath $script:HuntRoot -Filter *.kql -File -Recurse) } It 'carries no time filter - the window belongs to -Days' { # Same rule as kql/Law: the window is a server-side parameter, and one baked into a file # is invisible at the call site and intersects silently with what was asked for. $offenders = $script:HuntFiles | ForEach-Object { $code = ((Get-Content -LiteralPath $_.FullName) | Where-Object { $_ -notmatch '^\s*//' }) -join "`n" if ($code -match '\bago\s*\(' -or $code -match 'Timestamp\s*[<>]') { $_.Name } } $offenders | Should -BeNullOrEmpty } It 'gives every subject an All.kql, which is the default -Name' { foreach ($dir in Get-ChildItem -LiteralPath $script:HuntRoot -Directory) { (Join-Path $dir.FullName 'All.kql') | Should -Exist } } It 'collapses DeviceInfo to one row per device' { # DeviceInfo writes a row per device per day. Without arg_max a "device count" counts # observations instead, inflating silently with the length of the window. $q = Get-Content -LiteralPath (Join-Path $script:HuntRoot 'Device/All.kql') -Raw $q | Should -Match 'arg_max\(Timestamp, \*\) by DeviceId' } It 'says in the file that the vulnerability snapshot ignores the window' { # DeviceTvmSoftwareVulnerabilities has no Timestamp column, so -Days cannot apply. The # caller has to be told, or an unchanged row count across windows looks like a bug. $q = Get-Content -LiteralPath (Join-Path $script:HuntRoot 'Vulnerability/All.kql') -Raw $q | Should -Match 'NO Timestamp' } } |