Tests/Search-MsecAzureResourceGraph.Tests.ps1
|
#Requires -Module Pester # # Tests for Search-MsecAzureResourceGraph. Verifies the .kql-file convention, the # Az.ResourceGraph response unwrap, error handling for missing query files, and # the tab-completion pathway (which has caught real bugs that InModuleScope # would miss because it inherits the module's $script: state). BeforeAll { $modulePath = Join-Path $PSScriptRoot '..' 'msec.psm1' Import-Module $modulePath -Force -ErrorAction Stop # Redirect the completion cache for the whole file. An unscoped Search-MsecAzureResourceGraph # enumerates subscriptions and refreshes the cache, and with Get-AzSubscription mocked that # writes MOCK data - which lands in the developer's real cache and leaves -Subscription # completing to 'sub-1'. There is a guard test in MsecCompletionCache.Tests.ps1 that fails if # a test file which can trigger a cache write forgets this. $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 } 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-MsecAzureResourceGraph' { BeforeEach { # Results are cached by default, so the same query run in two tests would serve the # first test's mocked rows to the second and the second's mock would look like it had # no effect. Start every test cache-cold. (Pester rejects BeforeEach at file root, so # this lives in each Describe that re-runs a query.) Get-ChildItem -LiteralPath $script:CacheDir -Filter *.json -Recurse -ErrorAction SilentlyContinue | Remove-Item -Force } It 'loads Kql/Graph/VM/All.kql by convention and shuttles it to Search-AzGraph unchanged' { $result = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query @( [pscustomobject]@{ Name = 'lin-1'; ResourceGroupName = 'rg-a'; Os = 'Linux' } ) } $rows = Search-MsecAzureResourceGraph -ResourceType VM [pscustomobject]@{ Rows = $rows; Query = $script:CapturedQuery } } # The query that hit Search-AzGraph is exactly what's in the .kql file - # no filter clauses appended. $result.Query | Should -Match "type =~ 'microsoft.compute/virtualmachines'" $result.Query | Should -Match 'project Name\s*=\s*name' $result.Query | Should -Not -Match 'Os =~' # no in-query filtering anymore $result.Query | Should -Not -Match 'Running ==' $result.Query | Should -Not -Match "ResourceGroupName =~" $result.Rows.Count | Should -Be 1 $result.Rows[0].Name | Should -Be 'lin-1' } It 'unwraps Search-AzGraph response objects that have a .Data array' { # Reproduces what Az.ResourceGraph returns on some installs: ONE object with # .SkipToken (string) + .Data (array of rows). Without unwrapping, downstream # Where-Object can only filter on 'SkipToken' / 'Data' - useless. $rows = 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]@{ SkipToken = '' Data = @( [pscustomobject]@{ Name = 'lin-1'; Os = 'Linux'; Running = $true } [pscustomobject]@{ Name = 'win-1'; Os = 'Windows'; Running = $true } ) } } Search-MsecAzureResourceGraph -ResourceType VM } # We should see two rows, with the actual VM properties accessible for filtering. $rows.Count | Should -Be 2 ($rows | Where-Object Os -eq 'Linux').Name | Should -Be 'lin-1' # SkipToken / Data must not leak through as row properties. $rows[0].PSObject.Properties.Name | Should -Not -Contain 'SkipToken' $rows[0].PSObject.Properties.Name | Should -Not -Contain 'Data' } It '-CurrentSubscription scopes the query to just the active context subscription' { # Must NOT enumerate every accessible sub - it should pass only the current one # to Search-AzGraph. $sub = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'sub-current' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { throw 'should not enumerate all subscriptions with -CurrentSubscription' } $script:CapturedSub = $null Mock Search-AzGraph -MockWith { $script:CapturedSub = $Subscription; @() } $null = Search-MsecAzureResourceGraph -ResourceType VM -CurrentSubscription , $script:CapturedSub } $sub | Should -Be @('sub-current') } It 'resolves -Subscription by name, passes an id straight through, and refuses an ambiguous name' { # Names are what people remember, but they are not unique - this estate has three # subscriptions called 'Cloud Subscription'. An id must therefore still work, and an # ambiguous name must fail with the candidates rather than pick one. $captured = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 't1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = '11111111-1111-1111-1111-111111111111'; Name = 'Contoso-Prod'; TenantId = 't1' }, [pscustomobject]@{ Id = '44444444-4444-4444-4444-444444444444'; Name = 'Shared-Name'; TenantId = 't1' }, [pscustomobject]@{ Id = '55555555-5555-5555-5555-555555555555'; Name = 'Shared-Name'; TenantId = 't2' }) } $script:CapturedSub = $null Mock Search-AzGraph -MockWith { $script:CapturedSub = $Subscription; @() } $null = Search-MsecAzureResourceGraph -ResourceType VM -Subscription 'Contoso-Prod' $byName = $script:CapturedSub # A GUID needs no lookup at all, so this must not depend on enumeration succeeding. $null = Search-MsecAzureResourceGraph -ResourceType VM -Subscription '99999999-9999-9999-9999-999999999999' [pscustomobject]@{ ByName = $byName; ById = $script:CapturedSub } } $captured.ByName | Should -Be @('11111111-1111-1111-1111-111111111111') $captured.ById | Should -Be @('99999999-9999-9999-9999-999999999999') InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 't1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = '44444444-4444-4444-4444-444444444444'; Name = 'Shared-Name'; TenantId = 't1' }, [pscustomobject]@{ Id = '55555555-5555-5555-5555-555555555555'; Name = 'Shared-Name'; TenantId = 't2' }) } Mock Search-AzGraph -MockWith { @() } { Search-MsecAzureResourceGraph -ResourceType VM -Subscription 'Shared-Name' } | Should -Throw -ExpectedMessage '*ambiguous*' { Search-MsecAzureResourceGraph -ResourceType VM -Subscription 'No-Such-Sub' } | Should -Throw -ExpectedMessage '*not found. Available: Shared-Name*' } } It 'reuses a cached result, honours -NoCache, and never crosses subscription scopes' { # Caching is on by default. The scope label is what keeps that safe: a result gathered # for one subscription must never be served to an unscoped call, which would report a # fraction of the estate as all of it. $calls = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1'; Name = 'Contoso-Prod'; TenantId = 'tenant-1' }) } $script:Calls = 0 Mock Search-AzGraph -MockWith { $script:Calls++; @([pscustomobject]@{ Name = 'vm-1' }) } $null = Search-MsecAzureResourceGraph -ResourceType VM # live $first = $script:Calls $null = Search-MsecAzureResourceGraph -ResourceType VM # cached $second = $script:Calls $null = Search-MsecAzureResourceGraph -ResourceType VM -NoCache # forced live $third = $script:Calls $null = Search-MsecAzureResourceGraph -ResourceType VM -CurrentSubscription # different scope [pscustomobject]@{ First = $first; Second = $second; Third = $third; Scoped = $script:Calls } } $calls.First | Should -Be 1 # first call queries Azure $calls.Second | Should -Be 1 # second is served from cache $calls.Third | Should -Be 2 # -NoCache goes back to Azure $calls.Scoped | Should -Be 3 # a different scope must not reuse the unscoped result } It 'throws when -CurrentSubscription and -Subscription are both supplied' { InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Search-AzGraph -MockWith { @() } { Search-MsecAzureResourceGraph -ResourceType VM -CurrentSubscription -Subscription 'sub-2' } | Should -Throw -ExpectedMessage '*either -CurrentSubscription or -Subscription*' } } It 'throws a clear error when the named .kql file does not exist' { 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 { @() } { Search-MsecAzureResourceGraph -ResourceType VM -Name 'NoSuchQuery' } | Should -Throw -ExpectedMessage 'KQL query file not found:*' } } # Tab-completion tests go through PowerShell's real completion entry point # (TabExpansion2), NOT InModuleScope + & $scriptblock - the latter passed even when the # completer was actually broken because it ran the scriptblock with module $script: # scope available, whereas the real completion engine does not. These tests would # catch a regression to module-scoped state usage in the completer scriptblock. It 'tab completion for -ResourceType returns folders containing .kql files' { $line = 'Search-MsecAzureResourceGraph -ResourceType ' $result = TabExpansion2 -inputScript $line -cursorColumn $line.Length $result.CompletionMatches.CompletionText | Should -Contain 'VM' } It 'tab completion for -Name returns the .kql file basenames in the chosen ResourceType folder' { $line = 'Search-MsecAzureResourceGraph -ResourceType VM -Name ' $result = TabExpansion2 -inputScript $line -cursorColumn $line.Length $result.CompletionMatches.CompletionText | Should -Contain 'All' } It 'pipes cleanly through Where-Object into Invoke-MsecAzureVMScript' { # End-to-end: ARG-shaped rows -> Where-Object -> runner. Validates that # Search-MsecAzureResourceGraph's output (Name + ResourceGroupName) binds correctly to the runner. $captured = 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 = 'lin-1'; ResourceGroupName = 'rg-a'; Os = 'Linux'; Running = $true } [pscustomobject]@{ Name = 'win-1'; ResourceGroupName = 'rg-a'; Os = 'Windows'; Running = $true } ) } $script:CapturedVmNames = @() Mock Invoke-AzVMRunCommand -MockWith { $script:CapturedVmNames += $Name [pscustomobject]@{ Status = 'Succeeded'; Value = @() } } Search-MsecAzureResourceGraph -ResourceType VM | Where-Object Os -eq Linux | Invoke-MsecAzureVMScript -Os Linux -ScriptName os-info -TimeoutSeconds 0 | Out-Null ,$script:CapturedVmNames } $captured.Count | Should -Be 1 $captured[0] | Should -Be 'lin-1' } } Describe 'Search-MsecAzureResourceGraph pagination' { BeforeEach { # Every test here runs VM/All; without clearing, the second onwards would be served the # first one's cached rows instead of exercising its own paging mock. Get-ChildItem -LiteralPath $script:CacheDir -Filter *.json -Recurse -ErrorAction SilentlyContinue | Remove-Item -Force InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } } } It 'follows the skip token and returns every page, not just the first 1000 rows' { # KeyVault/NetworkRules is over 1100 rows on a mid-sized tenant, so single-page # behaviour silently dropped real findings off the end of a security report. $rows = InModuleScope msec { $script:Calls = 0 Mock Search-AzGraph -MockWith { $script:Calls++ if ($script:Calls -eq 1) { [pscustomobject]@{ SkipToken = 'tok-1'; Data = @([pscustomobject]@{ Name = 'page1' }) } } else { [pscustomobject]@{ SkipToken = ''; Data = @([pscustomobject]@{ Name = 'page2' }) } } } Search-MsecAzureResourceGraph -ResourceType VM } $rows.Count | Should -Be 2 $rows[0].Name | Should -Be 'page1' $rows[1].Name | Should -Be 'page2' # SkipToken must not leak through as a row property. $rows[0].PSObject.Properties.Name | Should -Not -Contain 'SkipToken' } It 'passes the previous page token back to Search-AzGraph' { $token = InModuleScope msec { $script:Calls = 0 $script:SeenToken = $null Mock Search-AzGraph -MockWith { $script:Calls++ if ($script:Calls -eq 1) { [pscustomobject]@{ SkipToken = 'tok-1'; Data = @([pscustomobject]@{ Name = 'a' }) } } else { $script:SeenToken = $SkipToken [pscustomobject]@{ SkipToken = ''; Data = @() } } } $null = Search-MsecAzureResourceGraph -ResourceType VM $script:SeenToken } $token | Should -Be 'tok-1' } It 'stops at -MaxRows with a warning rather than truncating silently' { # An under-reported security query is indistinguishable from a clean one, so the # ceiling must be loud. The mock never stops handing out tokens. $result = InModuleScope msec { Mock Search-AzGraph -MockWith { [pscustomobject]@{ SkipToken = 'always-more' Data = @([pscustomobject]@{ Name = 'x' }, [pscustomobject]@{ Name = 'y' }) } } $rows = Search-MsecAzureResourceGraph -ResourceType VM -MaxRows 3 ` -WarningVariable w -WarningAction SilentlyContinue [pscustomobject]@{ Rows = @($rows); Warnings = @($w) } } $result.Rows.Count | Should -Be 3 # Filtered, not counted outright: the point is that truncation is reported ONCE and # not per page, which is a claim about this command's warnings only. Counting every # warning in the variable also counts Az.Accounts' version-upgrade advisory, which # has nothing to do with paging and is not present on every machine. @($result.Warnings | Where-Object { $_ -match 'INCOMPLETE' }).Count | Should -Be 1 } It 'makes exactly one call when the response is a bare row array with no token' { $calls = InModuleScope msec { $script:Calls = 0 Mock Search-AzGraph -MockWith { $script:Calls++ @([pscustomobject]@{ Name = 'only' }) } $null = Search-MsecAzureResourceGraph -ResourceType VM $script:Calls } $calls | Should -Be 1 } } Describe 'Kql/Graph/KeyVault' { It 'tab-completes KeyVault and both of its query names' { $line = 'Search-MsecAzureResourceGraph -ResourceType ' (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText | Should -Contain 'KeyVault' $line = 'Search-MsecAzureResourceGraph -ResourceType KeyVault -Name ' $names = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText $names | Should -Contain 'All' $names | Should -Contain 'NetworkRules' } It 'All.kql treats a missing networkAcls block as open, not unknown' { $query = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $null = Search-MsecAzureResourceGraph -ResourceType KeyVault $script:CapturedQuery } $query | Should -Match "type =~ 'microsoft.keyvault/vaults'" # A vault with no firewall reports an EMPTY defaultAction and behaves as Allow. # Both halves of that test must be present or the open vaults hide. $query | Should -Match "isempty\(DefaultAction\) or DefaultAction =~ 'Allow'" foreach ($state in 'PrivateOnly', 'OpenToAllNetworks', 'Restricted') { $query | Should -Match "'$state'" } # Absent publicNetworkAccess means Enabled - defaulting it the other way would # report public vaults as private. $query | Should -Match "isempty\(PublicNetworkAccess\), 'Enabled'" } It 'NetworkRules.kql covers ip rules, vnet rules and vaults with neither' { $query = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $null = Search-MsecAzureResourceGraph -ResourceType KeyVault -Name NetworkRules $script:CapturedQuery } foreach ($ruleType in 'IPv4', 'VirtualNetwork', 'None') { $query | Should -Match "RuleType = '$ruleType'" } # mv-expand drops vaults whose rule arrays are empty, so the third leg is what keeps # an unconfigured vault - the whole point of the report - in the results. $query | Should -Match 'array_length\(properties\.networkAcls\.ipRules\), 0\) == 0' # An allow-list only enforces when the default is Deny AND the door is open. $query | Should -Match "Effective = \(DefaultAction =~ 'Deny'\) and \(PublicNetworkAccess =~ 'Enabled'\)" } } Describe 'Kql/Graph/Storage' { It 'tab-completes Storage and both of its query names' { $line = 'Search-MsecAzureResourceGraph -ResourceType ' (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText | Should -Contain 'Storage' $line = 'Search-MsecAzureResourceGraph -ResourceType Storage -Name ' $names = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText $names | Should -Contain 'All' $names | Should -Contain 'NetworkRules' } It 'All.kql defaults the three absent-means-permissive properties the permissive way' { # allowBlobPublicAccess, allowSharedKeyAccess and minimumTlsVersion are routinely # ABSENT rather than set, and absent means permissive for all three. Coalescing them # to false / TLS1_2 would silently under-report exposure - the failure mode that # makes a security report worse than no report. $query = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $null = Search-MsecAzureResourceGraph -ResourceType Storage $script:CapturedQuery } $query | Should -Match "type =~ 'microsoft.storage/storageaccounts'" $query | Should -Match 'BlobPublicAccessAllowed = coalesce\(tobool\(properties\.allowBlobPublicAccess\), true\)' $query | Should -Match 'SharedKeyAccessAllowed = coalesce\(tobool\(properties\.allowSharedKeyAccess\), true\)' $query | Should -Match "'TLS1_0', tostring\(properties\.minimumTlsVersion\)" foreach ($state in 'PrivateOnly', 'SecuredByPerimeter', 'OpenToAllNetworks', 'Restricted') { $query | Should -Match "'$state'" } } It 'NetworkRules.kql covers all four rule kinds plus accounts with none' { $query = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $null = Search-MsecAzureResourceGraph -ResourceType Storage -Name NetworkRules $script:CapturedQuery } foreach ($ruleType in 'IPv4', 'IPv6', 'VirtualNetwork', 'ResourceAccess', 'None') { $query | Should -Match "RuleType = '$ruleType'" } # The None leg must test ALL FOUR arrays. Omitting one would drop every account whose # only rules are of that kind from the "no rules" bucket AND leave it uncounted. foreach ($array in 'ipRules', 'ipv6Rules', 'virtualNetworkRules', 'resourceAccessRules') { $query | Should -Match "array_length\(properties\.networkAcls\.$array\), 0\) == 0" } $query | Should -Match "Effective = \(DefaultAction =~ 'Deny'\) and \(PublicNetworkAccess =~ 'Enabled'\)" } } Describe 'Kql/Graph/NSG' { It 'tab-completes NSG and both of its query names' { $line = 'Search-MsecAzureResourceGraph -ResourceType ' (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText | Should -Contain 'NSG' $line = 'Search-MsecAzureResourceGraph -ResourceType NSG -Name ' $names = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText $names | Should -Contain 'All' $names | Should -Contain 'SecurityRules' } It 'SecurityRules.kql projects every column the old script produced, plus IsDefault, and fan-out is per-rule' { $query = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $null = Search-MsecAzureResourceGraph -ResourceType NSG -Name SecurityRules $script:CapturedQuery } $query | Should -Match "type =~ 'microsoft.network/networksecuritygroups'" # One row per rule, not per NSG. $query | Should -Match 'mv-expand Rule = Rules' # Every column the script it replaces produced, plus IsDefault. foreach ($column in 'NsgName', 'AppliedToType', 'AppliedToName', 'RuleName', 'Direction', 'Priority', 'Access', 'Protocol', 'Source', 'SourcePort', 'Destination', 'DestinationPort', 'IsDefault', 'SubscriptionName') { $query | Should -Match "\b$column\b" } # All three ways an NSG can be referenced must be covered, or an NSG that is in use # is reported as an orphan. NICs alone - what the old script walked - is not enough: # a subnet-bound NSG has no NICs, and a Uniform scale set's instance NICs are child # resources that never appear in ARG's networkinterfaces table. $query | Should -Match 'microsoft.network/networkinterfaces' $query | Should -Match "AppliedToType = 'Subnet'" $query | Should -Match 'microsoft.compute/virtualmachinescalesets' $query | Should -Match "AppliedToType = 'VMSS'" # The attachment lookup must NOT be collapsed into one label per NSG: the fan-out is # what keeps AppliedToType / AppliedToName scalar and groupable. $query | Should -Not -Match 'summarize\s+AppliedTo' } It 'All.kql flags NSGs that are attached to nothing' { $query = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $null = Search-MsecAzureResourceGraph -ResourceType NSG $script:CapturedQuery } $query | Should -Match "type =~ 'microsoft.network/networksecuritygroups'" # VmssCount must be part of the sum. An NSG used only by a Uniform scale set has # SubnetCount = NicCount = 0 and would otherwise be reported as safe to delete. $query | Should -Match 'Attached\s*=\s*\(SubnetCount \+ NicCount \+ VmssCount\) > 0' $query | Should -Match 'microsoft.compute/virtualmachinescalesets' } } Describe 'Kql/Graph/MySQL' { It 'tab-completes MySQL as a ResourceType' { $line = 'Search-MsecAzureResourceGraph -ResourceType ' (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText | Should -Contain 'MySQL' } It 'All.kql classifies the network access model' { # NetworkMode is the whole point of this query: Resource Graph cannot reach a # flexible server's firewall rules (no child type is projected), so the access model # is the closest thing to an exposure answer that KQL alone can give. $query = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $null = Search-MsecAzureResourceGraph -ResourceType MySQL $script:CapturedQuery } $query | Should -Match "type =~ 'microsoft.dbformysql/flexibleservers'" foreach ($mode in 'VNetIntegrated', 'PrivateEndpoint', 'Public', 'Isolated') { $query | Should -Match "'$mode'" } $query | Should -Match '\bPublicNetworkAccess\b' } } Describe 'Kql/Graph/AppGateway' { BeforeAll { $script:AppGwQueries = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $captured = @{} foreach ($queryName in 'All', 'Sites', 'Backends') { $null = Search-MsecAzureResourceGraph -ResourceType AppGateway -Name $queryName $captured[$queryName] = $script:CapturedQuery } $captured } } It 'tab-completes AppGateway and all three of its query names' { $line = 'Search-MsecAzureResourceGraph -ResourceType ' (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText | Should -Contain 'AppGateway' $line = 'Search-MsecAzureResourceGraph -ResourceType AppGateway -Name ' $names = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText foreach ($queryName in 'All', 'Sites', 'Backends') { $names | Should -Contain $queryName } } It 'All.kql reads both places a gateway WAF can live, and tests the tier with contains' { # A WAF_v2 gateway keeps its WAF in a separate policy and leaves the inline block absent; # a v1 does the opposite. Reading one and not the other reports "no WAF" on a protected # estate. And 'WAF_v2' is a single term to `has` - the underscore is not a separator - so # `Tier has 'WAF'` is false on every v2 gateway and misclassifies the lot. $query = $AppGwQueries['All'] $query | Should -Match 'properties\.webApplicationFirewallConfiguration' $query | Should -Match 'microsoft\.network/applicationgatewaywebapplicationfirewallpolicies' $query | Should -Match "Tier !contains 'WAF'" foreach ($source in 'Policy', 'PolicyUnresolved', 'InlineConfig', 'NotWafSku', 'None') { $query | Should -Match "'$source'" } } It 'Sites.kql resolves the three-level WAF precedence a listener is subject to' { # Listener policy beats gateway policy beats the legacy inline config. Reading only the # gateway level silently mis-reports every listener that overrides it. $query = $AppGwQueries['Sites'] $query | Should -Match 'LP\.firewallPolicy\.id' $query | Should -Match 'GwProps\.firewallPolicy\.id' foreach ($source in 'ListenerPolicy', 'GatewayPolicy', 'GatewayInlineConfig') { $query | Should -Match "'$source'" } } } Describe 'Kql/Graph/Waf' { BeforeAll { $script:WafQueries = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $captured = @{} foreach ($queryName in 'All', 'ManagedRules', 'Exclusions', 'CustomRules') { $null = Search-MsecAzureResourceGraph -ResourceType Waf -Name $queryName $captured[$queryName] = $script:CapturedQuery } $captured } } It 'tab-completes Waf and all four of its query names' { $line = 'Search-MsecAzureResourceGraph -ResourceType ' (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText | Should -Contain 'Waf' $line = 'Search-MsecAzureResourceGraph -ResourceType Waf -Name ' $names = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText foreach ($queryName in 'All', 'ManagedRules', 'Exclusions', 'CustomRules') { $names | Should -Contain $queryName } } It 'covers the legacy inline WAF block, not just policy resources' { # A v1 gateway keeps its WAF on the gateway itself. Querying only the policy type reports # a v1 estate as having no WAF, no disabled rule groups and no exclusions - the gaps read # as a clean configuration. Custom rules are the exception: v1 WAF has no such concept. foreach ($queryName in 'All', 'ManagedRules', 'Exclusions') { $WafQueries[$queryName] | Should -Match 'properties\.webApplicationFirewallConfiguration' $WafQueries[$queryName] | Should -Match "'GatewayInlineConfig'" } $WafQueries['CustomRules'] | Should -Not -Match 'webApplicationFirewallConfiguration' } It 'keeps the rows an empty array would otherwise erase' { # mv-expand drops a row whose array is empty, and every one of these absences is a # finding: a rule set with no overrides, an exclusion scoped to nothing (the widest kind # there is), a policy with no custom rules. $WafQueries['ManagedRules'] | Should -Match 'mv-expand Rs = iff\(coalesce\(array_length' $WafQueries['ManagedRules'] | Should -Match "'RuleSetDefaults'" $WafQueries['Exclusions'] | Should -Match "'AllManagedRules'" $WafQueries['CustomRules'] | Should -Match 'array_length\(PolicyProps\.customRules\), 0\) == 0' } } Describe 'Kql/Graph/Resource' { BeforeAll { $script:ResQueries = InModuleScope msec { Mock Get-AzContext -MockWith { [pscustomobject]@{ Subscription = @{ Id = 'sub-1' }; Tenant = @{ Id = 'tenant-1' } } } Mock Get-AzSubscription -MockWith { @([pscustomobject]@{ Id = 'sub-1' }) } $script:CapturedQuery = $null Mock Search-AzGraph -MockWith { $script:CapturedQuery = $Query; @() } $captured = @{} foreach ($queryName in 'All', 'Inventory') { $null = Search-MsecAzureResourceGraph -ResourceType Resource -Name $queryName $captured[$queryName] = $script:CapturedQuery } $captured } } It 'tab-completes Resource and both of its query names' { $line = 'Search-MsecAzureResourceGraph -ResourceType ' (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText | Should -Contain 'Resource' $line = 'Search-MsecAzureResourceGraph -ResourceType Resource -Name ' $names = (TabExpansion2 -inputScript $line -cursorColumn $line.Length).CompletionMatches.CompletionText $names | Should -Contain 'All' $names | Should -Contain 'Inventory' } It 'shares a byte-identical classification map between the two queries' { # Resource Graph cannot share an expression across .kql files, so the map is duplicated. # Duplicated and DIVERGED would mean the type view and the resource view disagree about # what is PaaS, which is worse than either being wrong on its own. $begin = '>>> BEGIN CLASSIFICATION MAP' $end = '>>> END CLASSIFICATION MAP' $blocks = foreach ($queryName in 'All', 'Inventory') { $q = $ResQueries[$queryName] $q.Substring($q.IndexOf($begin), $q.IndexOf($end) - $q.IndexOf($begin) + $end.Length) } $blocks[0] | Should -BeExactly $blocks[1] } It 'never guesses, and puts exact-type overrides ahead of the provider defaults' { # case() takes the first match: if the microsoft.network default came first it would # swallow the overrides beneath it and DNS zones, Front Door, Firewall and Bastion would # all report as IaaS. An unmapped type must surface rather than be absorbed. $query = $ResQueries['All'] $query | Should -Match "'Unclassified\|Unknown'" $query.IndexOf("'microsoft.network/dnszones'") | Should -BeLessThan $query.IndexOf("Provider == 'microsoft.network',") } } Describe 'Bundled KQL files' { # Resource Graph accepts `let` for scalars only - a tabular `let` (the natural way to # name a lookup subquery) fails server-side with ParserFailure, which you only find out # by running it against a real tenant. Inline such lookups into join()/union() instead. It 'uses no tabular let statements, which Resource Graph cannot parse' { $kqlRoot = Join-Path (Get-Module msec).ModuleBase 'Kql' $offenders = Get-ChildItem -LiteralPath $kqlRoot -Filter *.kql -File -Recurse | Where-Object { # let <name> = <TableOrExpr> ... | ... (pipe on the same or a later line) (Get-Content -LiteralPath $_.FullName -Raw) -match '(?m)^\s*let\s+\w+\s*=\s*\w+\s*(\r?\n\s*)*\|' } | ForEach-Object { $_.FullName } $offenders | Should -BeNullOrEmpty } It 'every mv-expand in the AppGateway and Waf queries sets an explicit limit' { # Resource Graph's default mv-expand RowLimit is 128. An Application Gateway v2 allows # 200 listeners and 400 routing rules, and a DRS 2.1 policy can carry more overrides # than that, so the default silently truncates - and an under-reported security query # looks exactly like a clean one. # # Scoped to these two folders deliberately: the older queries predate this rule and # tightening them is a separate change with its own risk. $kqlRoot = Join-Path (Get-Module msec).ModuleBase 'Kql/Graph' $offenders = 'AppGateway', 'Waf' | ForEach-Object { Get-ChildItem -LiteralPath (Join-Path $kqlRoot $_) -Filter *.kql -File } | ForEach-Object { $file = $_ # Strip comments, then split into pipeline statements: an mv-expand over a # multi-line array_concat() or iff() carries its limit on a continuation line, so a # line-by-line check reports false offenders. $code = ((Get-Content -LiteralPath $file.FullName) | Where-Object { $_ -notmatch '^\s*//' }) -join "`n" ($code -split '(?m)^\s*\|') | Where-Object { $_ -match '^\s*mv-expand' -and $_ -notmatch 'limit\s+\d+' } | ForEach-Object { "$($file.Name): $(($_ -replace '\s+', ' ').Trim())" } } $offenders | Should -BeNullOrEmpty } It 'every extract_all pattern has a capture group' { # Resource Graph rejects a group-less extract_all pattern at PARSE time with # Functions_ArgumentRegexMatchingGroupCountInvalid, so a pattern used only to count # matches fails the entire query rather than returning an empty array. $kqlRoot = Join-Path (Get-Module msec).ModuleBase 'Kql' $offenders = Get-ChildItem -LiteralPath $kqlRoot -Filter *.kql -File -Recurse | ForEach-Object { $file = $_ $raw = Get-Content -LiteralPath $file.FullName -Raw [regex]::Matches($raw, "extract_all\(@'((?:[^']|'')*)'") | Where-Object { $_.Groups[1].Value -notmatch '(?<!\\)\((?!\?)' } | ForEach-Object { "$($file.Name): $($_.Groups[1].Value)" } } $offenders | Should -BeNullOrEmpty } } |