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'
    }
}

Describe 'Search-MsecDefenderHunting result shaping' {

    It 'strips @odata.type annotations, which doubled the columns of every summarize' {
        # THE BUG THIS PREVENTS. The hunting API sends a sibling key per typed value -
        # 'Events@odata.type' = '#Int64' beside 'Events' - so a summarize with two counts came
        # back as four columns and Format-Table was unreadable.
        $r = InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Invoke-MsecGraphRequest -MockWith {
                @{ results = @(
                    [pscustomobject]@{ 'Rule' = 'AsrUntrustedExecutable'
                                       'Events' = 13; 'Events@odata.type' = '#Int64'
                                       'Devices' = 3; 'Devices@odata.type' = '#Int64' }
                ) }
            }
            Search-MsecDefenderHunting -Query 'DeviceEvents | take 1'
        }

        $names = @($r.PSObject.Properties.Name)
        $names | Should -Contain 'Events'
        $names | Should -Contain 'Devices'
        $names | Should -Not -Contain 'Events@odata.type'
        $names | Should -Not -Contain 'Devices@odata.type'
        $r.Events | Should -Be 13
    }

    It 'keeps every property when the row arrives as a PSCustomObject' {
        # REGRESSION. The first version of the strip read $row.Keys, which is EMPTY on a
        # PSCustomObject - so it silently emitted objects with no properties at all rather
        # than failing, and the result looked like a query that matched nothing.
        $r = InModuleScope Msec {
            Mock Assert-MsecSession -MockWith { }
            Mock Invoke-MsecGraphRequest -MockWith {
                @{ results = @([pscustomobject]@{ A = 'one'; B = 'two'; C = 'three' }) }
            }
            Search-MsecDefenderHunting -Query 'DeviceEvents | take 1'
        }

        @($r.PSObject.Properties.Name).Count | Should -Be 3
        $r.B | Should -Be 'two'
    }
}

Describe 'Hunting/Device/AsrRule.kql' {

    BeforeAll {
        $script:HuntRoot = Join-Path (Get-Module Msec).ModuleBase 'kql/Hunting'
        $script:Asr = Get-Content -LiteralPath (Join-Path $script:HuntRoot 'Device/AsrRule.kql') -Raw
        $script:AsrCode = (($script:Asr -split "`n") | Where-Object { $_ -notmatch '^\s*//' }) -join "`n"
    }

    It 'carries no time filter, because the window is sent as its own timespan' {
        # A hardcoded ago() would fight the -Days the caller passed, and the API timespan wins -
        # so the file would look like it filtered and would not.
        $script:AsrCode | Should -Not -Match 'ago\('
    }

    It 'splits the outcome from the rule, because they answer different questions' {
        # "Audited" answers "what breaks if I enforce this"; "Blocked" answers "what broke".
        # Defender puts both in one ActionType string - AsrUntrustedExecutableBlocked.
        $script:AsrCode | Should -Match 'Outcome'
        $script:AsrCode | Should -Match 'Audited'
        $script:AsrCode | Should -Match 'Blocked'
        $script:AsrCode | Should -Match 'Rule\s*='
    }

    It 'exposes the window it actually got, so a short one is visible in the result' {
        # Search-MsecDefenderHunting defaults to SEVEN days. Measured on one tenant, the same
        # query returned 8 distinct days by default and 31 with -Days 30 - a four-fold
        # difference in event counts while the caller believed they asked for thirty.
        $script:AsrCode | Should -Match 'FirstSeen'
        $script:AsrCode | Should -Match 'Days\s*='
    }

    It 'counts devices, not only events, because one machine can dominate' {
        $script:AsrCode | Should -Match 'dcount\(DeviceName\)'
    }

    It 'leaves rule and device filtering to the caller' {
        $script:AsrCode | Should -Not -Match '(?i)viedoc|vie0[0-9]'
        $script:AsrCode | Should -Match 'FolderPath'
    }
}

Describe 'Hunting/Device/Process.kql' {

    BeforeAll {
        $script:HuntRoot2 = Join-Path (Get-Module Msec).ModuleBase 'kql/Hunting'
        $raw = Get-Content -LiteralPath (Join-Path $script:HuntRoot2 'Device/Process.kql') -Raw
        $script:ProcCode = (($raw -split "`n") | Where-Object { $_ -notmatch '^\s*//' }) -join "`n"
    }

    It 'carries no time filter, because the window is sent as its own timespan' {
        $script:ProcCode | Should -Not -Match 'ago\('
    }

    It 'keys identity on the hash, not the file name' {
        # The same FileName covers several builds - measured, 7zG.exe appeared as five
        # distinct SHA256s across five versions in thirty days. Grouping by name alone makes
        # "which version" unanswerable.
        $script:ProcCode | Should -Match 'SHA256'
        $script:ProcCode | Should -Match 'ProcessVersionInfoProductVersion'
    }

    It 'reports Renamed as a string with an empty third state' {
        # A KQL bool arrives through this API as Int64 0/1, so -eq 'True' silently matches
        # nothing. And barely half the events carry an OriginalFileName at all - measured,
        # 5,434,686 of 11,012,759 - so "no" would claim a check that was never possible.
        $script:ProcCode | Should -Match '"yes"'
        $script:ProcCode | Should -Match '"no"'
        $script:ProcCode | Should -Match 'isempty\(OriginalName\)'
        $script:ProcCode | Should -Not -Match 'iff\([^)]*true,\s*false\)'
    }

    It 'strips the extension from both sides before calling something renamed' {
        # Microsoft's own version resources omit it - UsoClient.exe declares "UsoClient" - and
        # comparing raw strings reported 13,523 "renamed" binaries led by Windows components.
        # Stripping the extension cut that to 7,042 genuine base-name differences.
        $script:ProcCode | Should -Match 'trim_end'
        $script:ProcCode | Should -Match 'OrigBase'
    }

    It 'does not treat the declared publisher as a signature' {
        # ProcessVersionInfoCompanyName is a PE version RESOURCE any author can set. Filtering
        # on it inside the query would bake a trust decision into an inventory.
        $script:ProcCode | Should -Not -Match 'Signer\s*(==|!=)\s*"'
        $script:ProcCode | Should -Not -Match 'ProcessVersionInfoCompanyName\s*(==|!=)'
    }
}