Tests/Search-MsecLogAnalytics.Tests.ps1
|
#Requires -Module Pester # # Tests for Search-MsecLogAnalytics: the Kql/Law file convention, workspace resolution through # Resource Graph and its two failure modes, and the invariants the bundled Law queries rely on. BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'Msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop # Redirect the completion cache for the whole file. Every test here resolves a workspace, # which refreshes the cache - with Search-AzGraph mocked that would write MOCK data into the # developer's real cache. $script:PrevCacheEnv = $env:MSEC_CACHE_DIR $env:MSEC_CACHE_DIR = Join-Path ([System.IO.Path]::GetTempPath()) "msec-test-cache-$([guid]::NewGuid())" New-Item -ItemType Directory -Path $env:MSEC_CACHE_DIR -Force | Out-Null $script:CacheDir = $env:MSEC_CACHE_DIR # Tenant-scoped now, so ask the module rather than assuming a flat layout - and mock the # context while asking. Get-MsecCachePath needs a tenant for the subfolder and THROWS # without one, which MSEC_CACHE_DIR does not satisfy: the tenant check comes first. # # A developer with an ambient `az login` never sees that. A CI runner has no context, so # this line threw in BeforeAll and Pester failed the whole CONTAINER - taking down all ten # tests in the file, including the four Kql ones that only read files off disk. 'tenant-1' # matches what every test below mocks, so setup and tests resolve the same cache folder. $script:CachePath = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Tenant = @{ Id = 'tenant-1' } } } Get-MsecCachePath -Name 'graph-loganalytics-all' } } AfterAll { if ($script:CacheDir -and (Test-Path -LiteralPath $script:CacheDir)) { Remove-Item -LiteralPath $script:CacheDir -Recurse -Force } $env:MSEC_CACHE_DIR = $script:PrevCacheEnv Remove-Module Msec -Force -ErrorAction SilentlyContinue } Describe 'Search-MsecLogAnalytics' { BeforeEach { # Workspace resolution reads the cached workspace list, so a previous test's mocked # workspaces would be served to the next one from disk. Get-ChildItem -LiteralPath $script:CacheDir -Filter *.json -Recurse -ErrorAction SilentlyContinue | Remove-Item -Force } It 'loads the .kql by convention, resolves the workspace name, and passes the window as a timespan' { $result = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } Mock Search-AzGraph -MockWith { @([pscustomobject]@{ Name = 'ws-1'; ResourceGroupName = 'rg-a' SubscriptionId = 'sub-1'; CustomerId = 'cid-1'; Location = 'westeurope' }) } $script:CapturedQuery = $null $script:CapturedId = $null $script:CapturedSpan = $null Mock Invoke-AzOperationalInsightsQuery -MockWith { $script:CapturedQuery = $Query $script:CapturedId = $WorkspaceId $script:CapturedSpan = $Timespan [pscustomobject]@{ Results = @([pscustomobject]@{ RuleId = '920230' }) } } $rows = Search-MsecLogAnalytics -Subject Waf -WorkspaceName ws-1 -Days 3 $byDays = $script:CapturedSpan $null = Search-MsecLogAnalytics -Subject Waf -WorkspaceName ws-1 -Timespan 4:00 [pscustomobject]@{ Rows = $rows; Query = $script:CapturedQuery; WorkspaceId = $script:CapturedId ByDays = $byDays; ByTimespan = $script:CapturedSpan } } # The workspace NAME is resolved to its customerId - that GUID is what the API takes - # and the window is a server-side timespan, not a clause appended to the query. $result.WorkspaceId | Should -Be 'cid-1' $result.ByDays | Should -Be ([TimeSpan]::FromDays(3)) $result.ByTimespan | Should -Be ([TimeSpan]::FromHours(4)) $result.Query | Should -Match 'ApplicationGatewayFirewallLog' $result.Rows[0].Workspace | Should -Be 'ws-1' } It 'lists the available workspaces when the name does not resolve' { # "Workspace not found" on its own sends you to the portal. With dozens of workspaces the # candidate list is the entire value of the error. InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } Mock Search-AzGraph -MockWith { @([pscustomobject]@{ Name = 'contoso-monitoring-log'; ResourceGroupName = 'rg-a' SubscriptionId = 'sub-1'; CustomerId = 'cid-1' }, [pscustomobject]@{ Name = 'contoso-sentinel-log'; ResourceGroupName = 'rg-b' SubscriptionId = 'sub-1'; CustomerId = 'cid-2' }) } Mock Invoke-AzOperationalInsightsQuery -MockWith { [pscustomobject]@{ Results = @() } } { Search-MsecLogAnalytics -Subject Waf -WorkspaceName typo-log } | Should -Throw -ExpectedMessage '*Available: contoso-monitoring-log, contoso-sentinel-log*' } } It 'refuses to guess when a workspace name is ambiguous' { # Two workspaces of the same name in different resource groups is normal in a sharded # estate. Picking one silently would query the wrong data and look completely fine. InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } Mock Search-AzGraph -MockWith { @([pscustomobject]@{ Name = 'dup-log'; ResourceGroupName = 'rg-a' SubscriptionId = 'sub-1'; CustomerId = 'cid-1' }, [pscustomobject]@{ Name = 'dup-log'; ResourceGroupName = 'rg-b' SubscriptionId = 'sub-2'; CustomerId = 'cid-2' }) } Mock Invoke-AzOperationalInsightsQuery -MockWith { [pscustomobject]@{ Results = @() } } { Search-MsecLogAnalytics -Subject Waf -WorkspaceName dup-log } | Should -Throw -ExpectedMessage '*ambiguous*' { Search-MsecLogAnalytics -Subject Waf -WorkspaceName dup-log -ResourceGroupName rg-b } | Should -Not -Throw } } It 'tab-completes -Subject and -Name from the Kql/Law tree' { $line = 'Search-MsecLogAnalytics -Subject ' (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText | Should -Contain 'Waf' $line = 'Search-MsecLogAnalytics -Subject Waf -Name ' $names = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText $names | Should -Contain 'All' $names | Should -Contain 'TopRules' } } Describe '-WorkspaceName completion' { It 'completes from the cached workspace query' { New-Item -ItemType Directory -Path (Split-Path -Parent $script:CachePath) -Force | Out-Null Set-Content -LiteralPath $script:CachePath -Encoding utf8 -Value (@{ UpdatedUtc = '2026-01-01T00:00:00Z' Items = @( @{ Name = 'contoso-sentinel-log'; ResourceGroupName = 'rg-sec'; Location = 'westeurope' } @{ Name = 'contoso-monitoring-log'; ResourceGroupName = 'rg-mon'; Location = 'francecentral' } ) } | ConvertTo-Json -Depth 4) $line = 'Search-MsecLogAnalytics -Subject Waf -WorkspaceName contoso-m' $names = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText $names | Should -Contain 'contoso-monitoring-log' $names | Should -Not -Contain 'contoso-sentinel-log' } It 'refreshes the cache even when the workspace name does not resolve' { # The first attempt at a half-remembered name is exactly when you most need completion to # start working. Enumerating BEFORE matching is what makes the retry tab-completable. $cached = InModuleScope Msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } Mock Search-AzGraph -MockWith { @([pscustomobject]@{ Name = 'newly-discovered-log'; ResourceGroupName = 'rg-x' SubscriptionId = 'sub-1'; CustomerId = 'cid-9' }) } Mock Invoke-AzOperationalInsightsQuery -MockWith { [pscustomobject]@{ Results = @() } } try { Search-MsecLogAnalytics -Subject Waf -WorkspaceName nope } catch { } Read-MsecCache -Name 'graph-loganalytics-all' } $cached.Name | Should -Contain 'newly-discovered-log' } } Describe 'Kql/Law bundled queries' { BeforeAll { $script:LawRoot = Join-Path (Get-Module Msec).ModuleBase 'Kql/Law' } It 'carries no time filter - the window belongs to -Days' { # -Days is passed to the API as a server-side timespan. A window baked into a file is # invisible at the call site and silently intersects with the one the caller asked for. $offenders = Get-ChildItem -LiteralPath $script:LawRoot -Filter *.kql -File -Recurse | ForEach-Object { $code = ((Get-Content -LiteralPath $_.FullName) | Where-Object { $_ -notmatch '^\s*//' }) -join "`n" if ($code -match 'TimeGenerated\s*[<>]' -or $code -match '\bago\s*\(') { $_.Name } } $offenders | Should -BeNullOrEmpty } It 'reads both App Gateway log modes and the GUID-suffixed transaction id' { # A per-gateway diagnostic setting decides whether firewall logs land in AzureDiagnostics # or AGWFirewallLogs; reading one means a setting change silently empties the report. # And AzureDiagnostics suffixes dynamic columns by TYPE - transactionId is a guid, so _g. # Reading _s returns nothing without erroring, which made dcount(TransactionId) report # exactly 1 on every row: a per-request count that was silently always one. foreach ($name in 'All', 'TopRules') { $q = Get-Content -LiteralPath (Join-Path $script:LawRoot "Waf/$name.kql") -Raw $q | Should -Match 'union isfuzzy=true' $q | Should -Match 'AGWFirewallLogs' $q | Should -Match "Category == 'ApplicationGatewayFirewallLog'" $q | Should -Match "column_ifexists\('transactionId_g'" $q | Should -Not -Match "column_ifexists\('transactionId_s'" } } It 'makes the VM overview resilient to a missing table but keeps the single-table variants strict' { # Kql/Law/VM/All is a cross-signal roster over a mixed estate, so a Linux-only workspace # (no Event/SecurityEvent) or a Windows-only one (no Syslog) must NOT fail the whole query - # that is what union isfuzzy=true buys. The per-signal variants target one table each and # deliberately do the opposite: no isfuzzy, so an absent table errors naming it rather than # lying with a blank result. $all = Get-Content -LiteralPath (Join-Path $script:LawRoot 'VM/All.kql') -Raw $all | Should -Match 'union isfuzzy=true' foreach ($table in 'Heartbeat', 'Event', 'Syslog', 'SecurityEvent') { $all | Should -Match $table } $singles = @{ Heartbeat = 'Heartbeat'; WindowsEvents = 'Event' Syslog = 'Syslog'; SecurityEvents = 'SecurityEvent' } foreach ($name in $singles.Keys) { $path = Join-Path $script:LawRoot "VM/$name.kql" # Comments talk ABOUT isfuzzy; the query code must not USE it. Strip comment lines the # same way the "no time filter" test does before asserting. $code = ((Get-Content -LiteralPath $path) | Where-Object { $_ -notmatch '^\s*//' }) -join "`n" $code | Should -Not -Match 'isfuzzy' $code | Should -Match $singles[$name] } } It 'shares a byte-identical normalization block between the two Waf queries' { # Duplicated because Log Analytics cannot share a fragment across files. Duplicated and # DIVERGED would mean Action and RuleId mean different things in the two views. $begin = '>>> BEGIN WAF LOG NORMALIZATION' $end = '>>> END WAF LOG NORMALIZATION' $blocks = foreach ($name in 'All', 'TopRules') { $q = (Get-Content -LiteralPath (Join-Path $script:LawRoot "Waf/$name.kql") -Raw) -replace "`r`n", "`n" $q.Substring($q.IndexOf($begin), $q.IndexOf($end) - $q.IndexOf($begin) + $end.Length) } $blocks[0] | Should -BeExactly $blocks[1] } } |