tests/Get-MsecSentinelRule.Tests.ps1

#Requires -Module Pester
#
# Tests for Get-MsecSentinelRule.
#
# The traps:
#
# RULEID MUST COME FROM THE RESOURCE NAME, not the display name. The resource name is the
# GUID that appears as alertPolicyId on every alert the rule raised, and it is the only
# reliable join between a rule and its alerts. Display names are edited, duplicated between a
# stock rule and a tuned copy, and localised.
#
# A NAMED WORKSPACE THAT IS NOT FOUND MUST THROW, NOT RETURN NOTHING. Building the request
# URL from an empty ResourceId produces '/providers/Microsoft.SecurityInsights/...' with no
# scope, which Azure rejects as an AUTHORIZATION failure - sending the reader to check RBAC
# for a workspace that was never located. That happened while writing this command.
#
# GROUPING ABSENT IS NOT GROUPING DISABLED. A Fusion rule carries no incidentConfiguration at
# all, so GroupingEnabled is $null; a Scheduled rule that has it switched off is $false.

BeforeAll {
    Import-Module (Join-Path $PSScriptRoot '..' 'Msec.psm1') -Force -ErrorAction Stop

    $script:StubbedAz = @()
    $azStubs = @{
        'Get-AzContext'                     = { [CmdletBinding()] param() }
        'Set-AzContext'                     = { [CmdletBinding()] param($SubscriptionId) }
        'Get-AzAccessToken'                 = { [CmdletBinding()] param($ResourceUrl) }
        'Get-AzOperationalInsightsWorkspace' = { [CmdletBinding()] param($ResourceGroupName, $Name) }
    }
    foreach ($c in $azStubs.Keys) {
        if (-not (Get-Command $c -ErrorAction SilentlyContinue)) {
            Set-Item "function:global:$c" -Value $azStubs[$c] -Force
            $script:StubbedAz += $c
        }
    }
}
AfterAll {
    foreach ($c in $script:StubbedAz) { Remove-Item "function:global:$c" -ErrorAction SilentlyContinue }
    Remove-Module msec -Force -ErrorAction SilentlyContinue
}

Describe 'Get-MsecSentinelRule' {

    # Defined here, not in the Describe body: Pester runs the body during discovery, so a
    # function declared there does not exist when the It blocks actually run.
    BeforeAll {
        function Get-RuleRows {
            param([string] $Kql, [switch] $NoQuery)
            InModuleScope msec -Parameters @{ Kql = $Kql; NoQuery = [bool] $NoQuery } {
                # Assigned into module scope: a -Parameters variable is NOT in scope when the mock
                # body below actually runs, and reading it there yields $null on every row.
                $script:TestKql     = $Kql
                $script:TestNoQuery = $NoQuery

                Mock Get-AzOperationalInsightsWorkspace -MockWith {
                    @([pscustomobject]@{ Name='ws'; ResourceGroupName='rg'; ResourceId='/subscriptions/sub-1/x' })
                }
                Mock Invoke-RestMethod -MockWith {
                    if ("$Uri" -match 'onboardingStates') { [pscustomobject]@{ value = @(1) } }
                    else {
                        $props = if ($script:TestNoQuery) {
                            [pscustomobject]@{ displayName='Fusion rule'; enabled=$true; severity='High' }
                        }
                        else {
                            [pscustomobject]@{ displayName='Scheduled rule'; enabled=$true; severity='High'; query=$script:TestKql }
                        }
                        [pscustomobject]@{ value = @([pscustomobject]@{ name='r1'; kind='Scheduled'; properties=$props }) }
                    }
                }
                Get-MsecSentinelRule
            }
        }
    }

    BeforeEach {
        InModuleScope msec {
            Mock Get-AzContext     -MockWith { [pscustomobject]@{ Subscription = [pscustomobject]@{ Id = 'sub-1' } } }
            Mock Get-AzAccessToken -MockWith { [pscustomobject]@{ Token = 'tok' } }
            Mock Get-MsecEnvironment -MockWith { [pscustomobject]@{ ArmResource = 'https://management.azure.com' } }
        }
    }

    It 'takes RuleId from the resource name, which is the join key to the alerts' {
        $rows = InModuleScope msec {
            Mock Get-AzOperationalInsightsWorkspace -MockWith {
                @([pscustomobject]@{ Name = 'ws'; ResourceGroupName = 'rg'; ResourceId = '/subscriptions/sub-1/x' })
            }
            Mock Invoke-RestMethod -MockWith {
                if ("$Uri" -match 'onboardingStates') { [pscustomobject]@{ value = @(1) } }
                else {
                    [pscustomobject]@{ value = @([pscustomobject]@{
                        name = '76f4905a-3204-422a-ab37-05a8ee27b303'
                        kind = 'Scheduled'
                        properties = [pscustomobject]@{
                            displayName = 'Azure DevOps Pull Request Policy Bypassing'
                            enabled = $true; severity = 'Medium'
                            alertRuleTemplateName = 'tpl-1'
                            incidentConfiguration = [pscustomobject]@{
                                createIncident = $true
                                groupingConfiguration = [pscustomobject]@{ enabled = $false; lookbackDuration = 'PT5H' }
                            }
                        } })
                    }
                }
            }
            Get-MsecSentinelRule
        }

        # The GUID, not the title - this is what alertPolicyId on an alert matches.
        $rows[0].RuleId | Should -Be '76f4905a-3204-422a-ab37-05a8ee27b303'
        $rows[0].DisplayName | Should -Be 'Azure DevOps Pull Request Policy Bypassing'
        $rows[0].GroupingEnabled | Should -BeFalse
        $rows[0].IsFromTemplate | Should -BeTrue
    }

    It 'reports GroupingEnabled as null when the rule carries no incident configuration' {
        $rows = InModuleScope msec {
            Mock Get-AzOperationalInsightsWorkspace -MockWith {
                @([pscustomobject]@{ Name = 'ws'; ResourceGroupName = 'rg'; ResourceId = '/subscriptions/sub-1/x' })
            }
            Mock Invoke-RestMethod -MockWith {
                if ("$Uri" -match 'onboardingStates') { [pscustomobject]@{ value = @(1) } }
                else {
                    # A Fusion rule has no incidentConfiguration at all.
                    [pscustomobject]@{ value = @([pscustomobject]@{
                        name = 'fusion-1'; kind = 'Fusion'
                        properties = [pscustomobject]@{ displayName = 'Advanced Multistage Attack Detection'; enabled = $true; severity = 'High' } }) }
                }
            }
            Get-MsecSentinelRule
        }

        # $false would claim grouping was looked at and found switched off.
        $rows[0].GroupingEnabled | Should -BeNullOrEmpty
        $rows[0].Kind | Should -Be 'Fusion'
    }

    It 'throws, naming the workspace and subscription, when a named workspace is not found' {
        InModuleScope msec {
            Mock Get-AzOperationalInsightsWorkspace -MockWith { throw "Operation returned an invalid status code 'NotFound'" }
            # An empty result here would build a scope-less URL and surface as an authorization
            # failure, sending the reader to check RBAC on a workspace that does not exist.
            { Get-MsecSentinelRule -ResourceGroupName rg -WorkspaceName nope } |
                Should -Throw "*workspace 'nope'*subscription sub-1*"
        }
    }

    It 'throws when a named workspace exists but is not onboarded to Sentinel' {
        InModuleScope msec {
            Mock Get-AzOperationalInsightsWorkspace -MockWith {
                @([pscustomobject]@{ Name = 'plain'; ResourceGroupName = 'rg'; ResourceId = '/subscriptions/sub-1/x' })
            }
            Mock Invoke-RestMethod -MockWith { throw 'Not Found' }
            # Returning nothing would read as a Sentinel with no rules, which is alarming and
            # wrong - this workspace simply is not a Sentinel.
            { Get-MsecSentinelRule -ResourceGroupName rg -WorkspaceName plain } |
                Should -Throw '*not onboarded to Microsoft Sentinel*'
        }
    }

    It 'skips non-Sentinel workspaces silently when discovering' {
        $rows = InModuleScope msec {
            Mock Get-AzOperationalInsightsWorkspace -MockWith {
                @(
                    [pscustomobject]@{ Name = 'plain';    ResourceGroupName = 'rg'; ResourceId = '/subscriptions/sub-1/plain' }
                    [pscustomobject]@{ Name = 'sentinel'; ResourceGroupName = 'rg'; ResourceId = '/subscriptions/sub-1/sentinel' }
                )
            }
            Mock Invoke-RestMethod -MockWith {
                if ("$Uri" -match 'onboardingStates') {
                    if ("$Uri" -match '/plain/') { throw 'Not Found' }
                    [pscustomobject]@{ value = @(1) }
                }
                else {
                    [pscustomobject]@{ value = @([pscustomobject]@{ name='r1'; kind='Scheduled'
                        properties = [pscustomobject]@{ displayName='Rule'; enabled=$true; severity='Low' } }) }
                }
            }
            Get-MsecSentinelRule -WarningAction SilentlyContinue
        }

        # Most Log Analytics workspaces are not Sentinels; warning on each would be noise.
        @($rows).Count | Should -Be 1
        $rows[0].WorkspaceName | Should -Be 'sentinel'
    }

    It 'returns only enabled rules with -EnabledOnly' {
        $rows = InModuleScope msec {
            Mock Get-AzOperationalInsightsWorkspace -MockWith {
                @([pscustomobject]@{ Name='ws'; ResourceGroupName='rg'; ResourceId='/subscriptions/sub-1/x' })
            }
            Mock Invoke-RestMethod -MockWith {
                if ("$Uri" -match 'onboardingStates') { [pscustomobject]@{ value = @(1) } }
                else {
                    [pscustomobject]@{ value = @(
                        [pscustomobject]@{ name='r1'; kind='Scheduled'; properties=[pscustomobject]@{ displayName='On';  enabled=$true;  severity='Low' } }
                        [pscustomobject]@{ name='r2'; kind='Scheduled'; properties=[pscustomobject]@{ displayName='Off'; enabled=$false; severity='Low' } }
                    ) }
                }
            }
            Get-MsecSentinelRule -EnabledOnly
        }

        @($rows).Count | Should -Be 1
        $rows[0].DisplayName | Should -Be 'On'
    }

    It 'warns rather than silently omitting a workspace whose rules cannot be read' {
        InModuleScope msec {
            Mock Get-AzOperationalInsightsWorkspace -MockWith {
                @([pscustomobject]@{ Name='ws'; ResourceGroupName='rg'; ResourceId='/subscriptions/sub-1/x' })
            }
            Mock Invoke-RestMethod -MockWith {
                if ("$Uri" -match 'onboardingStates') { [pscustomobject]@{ value = @(1) } }
                else { throw 'Forbidden' }
            }
            Get-MsecSentinelRule -WarningVariable w -WarningAction SilentlyContinue | Out-Null
            $script:captured = $w
        }
        $captured = InModuleScope msec { $script:captured }

        "$captured" | Should -Match 'MISSING from this output'
    }
    # ---- Query and QueryTables -------------------------------------------------------------
    #
    # QueryTables is a scan, not a KQL parser, and both of the regressions below came from a
    # live workspace rather than from imagination. Every one of these is a shape that appeared
    # in a real Content Hub or hand-written rule.

    It 'carries the KQL itself and the table it reads' {
        $rows = Get-RuleRows -Kql "SigninLogs`n| where ResultType == 0`n| summarize count() by UserPrincipalName"

        $rows[0].Query       | Should -Match 'where ResultType'
        $rows[0].QueryTables | Should -Be @('SigninLogs')
    }

    It 'reports Query as null, not empty string, on a rule that has no query of its own' {
        # A Fusion or NRT rule's correlation is Microsoft's. An empty string would claim a
        # query exists and happens to be blank.
        $rows = Get-RuleRows -NoQuery

        $rows[0].Query       | Should -BeNullOrEmpty
        $rows[0].Query       | Should -Not -Be ''
        $null -eq $rows[0].Query | Should -BeTrue
        $rows[0].QueryTables | Should -BeNullOrEmpty
    }

    It 'excludes let-bound names, which read exactly like table references and are not' {
        $kql = @'
let Threshold = 3;
let Blocked = AGWFirewallLogs | where Action == "Blocked" | distinct TransactionId;
Blocked
| where TransactionId != ""
'@

        $rows = Get-RuleRows -Kql $kql

        # 'Blocked' begins a statement and is followed by a pipe - and is a local, not a table.
        $rows[0].QueryTables | Should -Be @('AGWFirewallLogs')
    }

    It 'follows union legs and join operands inside parentheses' {
        $kql = @'
union isfuzzy=true
  (AzureDiagnostics | where Category == "ApplicationGatewayFirewallLog" | project TimeGenerated),
  (AGWFirewallLogs | project TimeGenerated)
| join kind=inner (
    AuditLogs
    | where OperationName =~ "Add user"
) on TimeGenerated
'@

        $rows = Get-RuleRows -Kql $kql

        $rows[0].QueryTables | Should -Be @('AGWFirewallLogs', 'AuditLogs', 'AzureDiagnostics')
    }

    It 'does not report a union modifier fragment as a table' {
        # REGRESSION. Matching an identifier after a bare 'union' keyword let the regex
        # backtrack into 'isfuzzy=true' and report 'e' as the only table of three live WAF
        # rules - while the two tables they actually read went missing.
        $rows = Get-RuleRows -Kql 'union isfuzzy=true (AGWFirewallLogs | project TimeGenerated)'

        $rows[0].QueryTables | Should -Be @('AGWFirewallLogs')
        $rows[0].QueryTables | Should -Not -Contain 'e'
    }

    It 'does not report the trailing column of a summarize as a table' {
        # REGRESSION. 'Uri' here is followed by a pipe exactly as a table reference would be.
        # What rules it out is the character BEFORE it: a comma, not ';', '(', '{' or the
        # start of the query.
        $kql = @'
AGWAccessSummary_CL
| summarize Total = dcount(TransactionId) by clientIp_s = ClientIp, Uri
| where Total >= 3
'@

        $rows = Get-RuleRows -Kql $kql

        $rows[0].QueryTables | Should -Be @('AGWAccessSummary_CL')
        $rows[0].QueryTables | Should -Not -Contain 'Uri'
    }

    It 'finds a table inside a user-defined function body' {
        $kql = @'
let awsFunc = (start:datetime) {
  AuditLogs
  | where TimeGenerated > start
  | where Category =~ "RoleManagement"
};
awsFunc(ago(1h))
'@

        $rows = Get-RuleRows -Kql $kql

        $rows[0].QueryTables | Should -Be @('AuditLogs')
    }

    It 'ignores table names in comments and in string literals' {
        $kql = @'
// This rule used to read AzureDiagnostics before the migration.
SigninLogs
| where ResultDescription == "see SecurityEvent for detail"
'@

        $rows = Get-RuleRows -Kql $kql

        $rows[0].QueryTables | Should -Be @('SigninLogs')
    }

    It 'returns no tables when the rule names its table at run time' {
        # DOCUMENTED LIMIT, not a bug to fix: the name is built from a string at execution
        # time, so nothing in the text identifies it. The help says empty means 'none found'
        # rather than 'reads nothing', which is why this is asserted rather than left to chance.
        $kql = @'
let aadFunc = (tableName:string){
table(tableName)
| where ResultType == "50057"
};
let aadSignin = aadFunc("SigninLogs");
aadSignin
'@

        $rows = Get-RuleRows -Kql $kql

        $rows[0].QueryTables | Should -BeNullOrEmpty
        # The KQL is still there, so the dependency remains discoverable by reading it.
        $rows[0].Query | Should -Match 'SigninLogs'
    }
}