public/Get-MsecSentinelRule.ps1

function Get-MsecSentinelRule {
    <#
    .SYNOPSIS
        Microsoft Sentinel analytics rules with their tuning state - severity, alert grouping,
        suppression - and the id that joins them to the alerts they produced.

    .DESCRIPTION
        A Sentinel workspace accumulates rules from the Content Hub faster than anyone tunes
        them, and the portal shows one rule at a time. This returns all of them as flat rows,
        so 'which rules have never been tuned' and 'which rules produce every alert we close as
        a false positive' are both one pipeline.

        RULEID IS THE JOIN TO THE ALERTS. A rule's resource name is a GUID, and that GUID is
        the alertPolicyId on every alert the rule raised - so RuleId joins directly to
        Get-MsecDefenderAlert's Raw.alertPolicyId. Matching on the display name instead looks
        equivalent and is not: titles are edited, duplicated between a stock rule and a tuned
        copy, and localised.

        TUNING STATE IS THE POINT, NOT THE QUERY. GroupingEnabled, SuppressionEnabled and
        TriggerThreshold are what decide how much noise a rule makes. Alert grouping in
        particular is off by default on every Content Hub rule, and with it off each alert
        becomes its own incident - measured on one workspace, 0 of 48 rules had it enabled.

        QUERYTABLES IS A SCAN OF THE KQL, NOT A PARSE, AND EMPTY MEANS 'NONE FOUND'. It answers
        the question a rule list cannot otherwise answer - which rules read a table that holds
        no data, because such a rule is enabled, green, and can never fire. Comments, string
        literals and let-bound names are excluded; union legs, join operands and function
        bodies are followed. Measured against a live workspace it resolved 19 of 20 scheduled
        rules correctly. The twentieth builds its table name at run time - 'table(tableName)'
        fed from a string - which no scan of the text can see, so it comes back empty. Treat an
        empty list as 'nothing found', never as 'reads nothing'; Query has the KQL itself.

        RUNS AS THE SIGNED-IN USER, NOT AS THE msec APP. Sentinel is an Azure resource, so this
        reads through ARM on your Az context like Get-MsecAzureSecureScore and
        Search-MsecAzureResourceGraph. The app certificate holds Graph permissions, not Azure
        RBAC. One command, one identity.

        A WORKSPACE THAT IS NOT ONBOARDED TO SENTINEL IS SKIPPED WHEN DISCOVERING, AND NAMED
        WHEN ASKED FOR. Discovery walks the Log Analytics workspaces in scope and most of them
        are ordinary log workspaces; naming one explicitly and getting silence back would read
        as a Sentinel with no rules, which is a different and much more alarming thing.

    .PARAMETER SubscriptionId
        Subscription to search. Defaults to the active Az context. Use Select-MsecAzureContext
        to move the context itself.

    .PARAMETER ResourceGroupName
        Only workspaces in this resource group.

    .PARAMETER WorkspaceName
        A specific Log Analytics workspace. Omit to find every Sentinel-onboarded workspace in
        scope.

    .PARAMETER EnabledOnly
        Only rules that are switched on.

    .EXAMPLE
        Get-MsecSentinelRule | Where-Object { $_.Enabled -and -not $_.GroupingEnabled }

        Enabled rules with alert grouping off - every alert becomes its own incident.

    .EXAMPLE
        $rules = Get-MsecSentinelRule
        $alerts = Get-MsecDefenderAlert
        $alerts | Group-Object { $_.Raw.alertPolicyId } | ForEach-Object {
            $rule = $rules | Where-Object RuleId -eq $_.Name
            [pscustomobject]@{
                Rule = if ($rule) { $rule.DisplayName } else { '(not a Sentinel rule)' }
                Alerts = $_.Count
                Grouping = $rule.GroupingEnabled
                Severity = $rule.Severity
            }
        } | Sort-Object Alerts -Descending

        Alert volume per rule, with whether that rule has ever been tuned. This is the join the
        command exists for.

    .EXAMPLE
        Get-MsecSentinelRule -EnabledOnly |
            Where-Object { $_.QueryTables -contains 'SigninLogs' }

        Which enabled rules depend on a given table. If that table is empty in this workspace,
        every rule listed here is switched on and cannot fire.

    .EXAMPLE
        Get-MsecSentinelRule -EnabledOnly |
            Group-Object Severity | Sort-Object Count -Descending

        How many rules sit at each severity. A tier with most of the rules in it is a volume
        band, not a severity.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [string] $SubscriptionId,

        [string] $ResourceGroupName,

        [string] $WorkspaceName,

        [switch] $EnabledOnly
    )

    $context = Get-AzContext -ErrorAction SilentlyContinue
    if (-not $context) {
        throw 'No Azure context. Run Connect-AzAccount first - this command runs as you, not as the msec app.'
    }

    if ($SubscriptionId -and $context.Subscription.Id -ne $SubscriptionId) {
        Write-Verbose "Switching context to subscription $SubscriptionId"
        $context = Set-AzContext -SubscriptionId $SubscriptionId -ErrorAction Stop
    }
    $subscription = $context.Subscription.Id

    $arm = (Get-MsecEnvironment).ArmResource
    $tokenResponse = Get-AzAccessToken -ResourceUrl "$arm/" -ErrorAction Stop
    $token = if ($tokenResponse.Token -is [System.Security.SecureString]) {
        [System.Net.NetworkCredential]::new('', $tokenResponse.Token).Password
    }
    else { [string] $tokenResponse.Token }
    $headers = @{ Authorization = "Bearer $token" }

    $workspaceParams = @{ ErrorAction = 'Stop' }
    if ($ResourceGroupName) { $workspaceParams['ResourceGroupName'] = $ResourceGroupName }
    if ($WorkspaceName -and $ResourceGroupName) { $workspaceParams['Name'] = $WorkspaceName }

    # Get-AzOperationalInsightsWorkspace throws its own bare "Operation returned an invalid
    # status code 'NotFound'" for a name that does not exist, which says nothing about WHICH
    # name or WHICH subscription was searched. Caught here so the message below is the one the
    # reader sees.
    $workspaces = @()
    try {
        $workspaces = @(Get-AzOperationalInsightsWorkspace @workspaceParams)
    }
    catch {
        if ("$($_.Exception.Message)" -notmatch 'NotFound|ResourceNotFound') { throw }
        Write-Verbose "Workspace lookup returned NotFound: $($_.Exception.Message)"
    }
    if ($WorkspaceName) { $workspaces = @($workspaces | Where-Object { $_.Name -eq $WorkspaceName }) }

    # Named and not found is an error, never an empty result. Building the request URL from an
    # empty ResourceId yields '/providers/Microsoft.SecurityInsights/...' with no scope at all,
    # which Azure rejects with an authorization failure - sending the reader to check RBAC for
    # a workspace that was never located. This happened while writing the command.
    if (-not $workspaces.Count) {
        $where = @(
            if ($WorkspaceName)     { "workspace '$WorkspaceName'" }
            if ($ResourceGroupName) { "resource group '$ResourceGroupName'" }
        ) -join ' in '
        if ($where) {
            throw "No Log Analytics $where found in subscription $subscription. Check the name, and that the active context is the right subscription - Get-AzContext shows which."
        }
        Write-Warning "No Log Analytics workspaces found in subscription $subscription, so no Sentinel rules could be read. This is not the same as a Sentinel with no rules."
        return
    }

    $onboarded = 0
    foreach ($workspace in $workspaces) {
        $base = "$arm$($workspace.ResourceId)/providers/Microsoft.SecurityInsights"

        # Onboarding is what separates a Sentinel from an ordinary log workspace. Probed per
        # workspace because there is no way to filter for it when listing.
        $isSentinel = $false
        try {
            $null = Invoke-RestMethod -Headers $headers -ErrorAction Stop `
                -Uri "$base/onboardingStates?api-version=2024-09-01"
            $isSentinel = $true
        }
        catch {
            if ($WorkspaceName) {
                throw "Workspace '$($workspace.Name)' exists but is not onboarded to Microsoft Sentinel, so it has no analytics rules. $($_.Exception.Message)"
            }
            Write-Verbose "Skipping '$($workspace.Name)': not onboarded to Sentinel."
            continue
        }
        if (-not $isSentinel) { continue }
        $onboarded++

        $rules = $null
        try {
            $rules = @((Invoke-RestMethod -Headers $headers -ErrorAction Stop `
                -Uri "$base/alertRules?api-version=2024-09-01").value)
        }
        catch {
            Write-Warning "Could not read analytics rules from '$($workspace.Name)', so it is MISSING from this output rather than reported as having none: $($_.Exception.Message)"
            continue
        }

        foreach ($rule in $rules) {
            $p = $rule.properties
            if ($EnabledOnly -and -not $p.enabled) { continue }

            $grouping = $p.incidentConfiguration.groupingConfiguration

            # THE TABLES THE RULE READS, found by scanning the KQL. Comments and string
            # literals are stripped first, so a table named in prose or inside a quoted
            # string is not counted as a dependency.
            $kql = [string] $p.query
            $stripped = ($kql -replace '(?m)//.*$', '') -replace '"[^"]*"', '""' -replace "'[^']*'", "''"

            # let-bound names are local, not tables: 'let Blocked = ... ; Blocked | where'
            # reads exactly like a table reference and is not one.
            $letNames = @([regex]::Matches($stripped, '(?m)\blet\s+([A-Za-z_][A-Za-z0-9_]*)\s*=') |
                          ForEach-Object { $_.Groups[1].Value })

            # A table reference is an identifier followed by a pipe AND preceded by the start
            # of the query, ';', '(' or '{' - the four places a KQL statement can begin: top
            # level, a union leg or join operand, and a user-defined function body.
            #
            # THE PRECEDING CHARACTER IS WHAT MAKES THIS WORK. Matching "identifier followed by
            # a pipe" alone also matches the last column of a summarize - 'by clientIp_s =
            # ClientIp, Uri | where ...' yields 'Uri' - and matching after a bare union/join
            # keyword instead picks up its own modifiers: against a live workspace that
            # returned 'e', the tail of 'isfuzzy=true' after the regex backtracked into it.
            $tableMatches = @([regex]::Matches($stripped, '(?s)(?:\A|[;({])\s*([A-Za-z_][A-Za-z0-9_]*)\s*\|') |
                              ForEach-Object { $_.Groups[1].Value })

            # 'let Blocked = AGWFirewallLogs | where ...' puts the table after an '=' sign.
            # Matched only in the let form: adding '=' to the class above would also take the
            # right-hand side of '| extend Foo = Bar | ...', which is a column.
            $tableMatches += @([regex]::Matches($stripped, '(?s)\blet\s+[A-Za-z_][A-Za-z0-9_]*\s*=\s*([A-Za-z_][A-Za-z0-9_]*)\s*\|') |
                               ForEach-Object { $_.Groups[1].Value })

            # Bare union operands - 'union A, B' with no parentheses - have no pipe of their
            # own. key=value modifiers are removed first rather than matched around, which is
            # the backtracking trap described above.
            foreach ($u in [regex]::Matches($stripped, '(?is)\bunion\b([^;|]*)')) {
                foreach ($operand in (($u.Groups[1].Value -replace '\w+\s*=\s*\w+', '') -split ',')) {
                    $operand = $operand.Trim()
                    if ($operand -match '^[A-Za-z_][A-Za-z0-9_]*$') { $tableMatches += $operand }
                }
            }

            $tables = @($tableMatches |
                        Where-Object { $_ -and $_ -notin $letNames -and
                                       $_ -notmatch '^(let|union|join|print|range|datatable|externaldata|find|search)$' } |
                        Sort-Object -Unique)

            [PSCustomObject]@{
                PSTypeName             = 'MsecSentinelRule'
                SubscriptionId         = $subscription
                ResourceGroupName      = $workspace.ResourceGroupName
                WorkspaceName          = $workspace.Name
                # The join key to Get-MsecDefenderAlert's Raw.alertPolicyId.
                RuleId                 = [string] $rule.name
                DisplayName            = [string] $p.displayName
                Kind                   = [string] $rule.kind
                Enabled                = [bool] $p.enabled
                Severity               = [string] $p.severity
                Tactics                = @($p.tactics)
                Techniques             = @($p.techniques)
                # THE KQL ITSELF, and the tables it reads. Without these the only way to
                # answer "which table does this rule depend on" was to reach into
                # Raw.properties.query by hand - which is how a rule pointed at an empty
                # table stays invisible: it is enabled, it is green, and it can never fire.
                #
                # NULL ON A FUSION OR NRT RULE, not empty string. Those have no query of
                # their own - the correlation is Microsoft's - so an empty string would
                # claim a query exists and is blank.
                Query                  = if ($p.PSObject.Properties.Name -contains 'query') { [string] $p.query } else { $null }
                # Best-effort, and empty means 'none found' rather than 'reads nothing' -
                # a rule naming its table at run time has none to find. See the help.
                QueryTables            = @($tables)

                # Scheduled rules only; a Fusion or NRT rule has no schedule of its own, so
                # these are null rather than being invented.
                QueryFrequency         = [string] $p.queryFrequency
                QueryPeriod            = [string] $p.queryPeriod
                TriggerOperator        = [string] $p.triggerOperator
                TriggerThreshold       = $p.triggerThreshold
                CreateIncident         = $p.incidentConfiguration.createIncident
                # The two tuning levers. Both default OFF on Content Hub rules.
                GroupingEnabled        = if ($null -eq $grouping) { $null } else { [bool] $grouping.enabled }
                GroupingLookback       = [string] $grouping.lookbackDuration
                GroupingMatchingMethod = [string] $grouping.matchingMethod
                SuppressionEnabled     = $p.suppressionEnabled
                SuppressionDuration    = [string] $p.suppressionDuration
                # A rule still carrying a template id and never modified since install is stock.
                TemplateId             = [string] $p.alertRuleTemplateName
                IsFromTemplate         = [bool] $p.alertRuleTemplateName
                LastModifiedUtc        = $p.lastModifiedUtc
                Raw                    = $rule
            }
        }
    }

    if (-not $WorkspaceName -and $onboarded -eq 0) {
        Write-Warning "None of the $($workspaces.Count) Log Analytics workspace(s) in subscription $subscription are onboarded to Microsoft Sentinel."
    }
}