public/Get-MsecDefenderDetectionRule.ps1

function Get-MsecDefenderDetectionRule {
    <#
    .SYNOPSIS
        Defender XDR custom detection rules - the scheduled advanced hunting queries that raise
        alerts - with their run status, schedule and the query behind each.

    .DESCRIPTION
        A custom detection rule is an advanced hunting query Defender runs on a schedule and
        turns into alerts. This lists them, so "do we detect that?" is answerable without
        opening the portal.

        NOT THE SAME THING AS Get-MsecSentinelRule, AND THE TWO ARE EASY TO CONFUSE because
        Microsoft calls both "detection rules". They live in different products, read different
        data, and neither can see the other's:

            Get-MsecDefenderDetectionRule Defender XDR queries advanced hunting tables
            Get-MsecSentinelRule Sentinel queries a Log Analytics workspace

        A tenant whose Defender data is not connected to Sentinel cannot write a Sentinel rule
        over DeviceEvents at all - measured on one tenant, 53 Sentinel rules and not one of them
        able to see a Defender device event, because no Device* table exists in the workspace.
        Asking the wrong command returns a confident list of the wrong rules.

        'AUTODISABLED' IS THE REASON THIS IS WORTH RUNNING. Defender switches a custom detection
        off by itself when its query starts failing - a renamed column, a table that stopped
        resolving, a schema change. The rule still exists, still appears in the portal list, and
        has silently stopped running. That is indistinguishable from a rule that is working and
        finding nothing, which is the most expensive failure a detection can have.

        STATUS, NOT ISENABLED. The isEnabled property was REMOVED from this resource on
        2026-10-01, along with detectorId and lastRunDetails. Code still reading isEnabled gets
        $null, which is falsy, and reports every rule as disabled. Status carries the same
        information plus the autoDisabled value isEnabled could never express.

    .PARAMETER Name
        Substring match on the display name, case-insensitive.

    .PARAMETER Status
        Only rules in this run state: 'enabled', 'disabled' or 'autoDisabled'.

    .PARAMETER Query
        Substring match on the rule's hunting query. 'DeviceEvents', 'Asr', a table name -
        answers "is anything watching this?" without reading every rule.

    .EXAMPLE
        Get-MsecDefenderDetectionRule | Where-Object Status -eq 'autoDisabled'

        Rules Defender has switched off because their query broke. They look live in the portal
        and are not running.

    .EXAMPLE
        Get-MsecDefenderDetectionRule -Query 'Asr'

        Whether anything detects on attack surface reduction events. Most ASR rules raise no
        alert of their own, so without a custom detection a block is invisible.

    .EXAMPLE
        Get-MsecDefenderDetectionRule | Select-Object DisplayName, Status, Frequency, NextRun, Severity

        The whole detection surface at a glance.

    .OUTPUTS
        PSCustomObject per rule, PSTypeName 'MsecDefenderDetectionRule'.

    .NOTES
        Needs the 'CustomDetection.Read.All' application permission, which New-MsecApp grants.
        An app created before that was added must re-run New-MsecApp and re-consent.

        Reads the beta endpoint: custom detection rules are beta-only in Microsoft Graph at the
        time of writing, and the run-detail properties are not exposed in v1.0 at all.
    #>

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

        [ArgumentCompleter({
            param($c, $p, $wordToComplete)
            @('enabled', 'disabled', 'autoDisabled') |
                Where-Object { $_ -like "$wordToComplete*" } |
                ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
        })]
        [string] $Status,

        [string] $Query
    )

    Assert-MsecSession

    try {
        $rules = @(Invoke-MsecGraphRequest -Path '/beta/security/rules/detectionRules' -All)
    }
    catch {
        $message = "$($_.Exception.Message)"
        if ($message -match '403|Forbidden') {
            throw "Forbidden when calling /security/rules/detectionRules. The msec app needs the 'CustomDetection.Read.All' application permission - this is NOT covered by ThreatHunting.Read.All, which only lets the app RUN hunting queries, not see the scheduled detections built on them. Re-run New-MsecApp to add and consent it, then Disconnect-Msec / Connect-Msec. Original error: $message"
        }
        throw
    }

    if (-not $rules.Count) {
        Write-Warning "No Defender XDR custom detection rules exist in this tenant. Note this is NOT the same as having no detections: Microsoft's own built-in analytics are separate and are not listed here, and Sentinel analytics rules are a different store entirely (Get-MsecSentinelRule)."
        return
    }

    $autoDisabled = 0

    foreach ($rule in $rules) {
        if ($Name -and "$($rule.displayName)" -notmatch [regex]::Escape($Name)) { continue }

        $queryText = [string] $rule.queryCondition.queryText
        if ($Query -and $queryText -notmatch [regex]::Escape($Query)) { continue }

        # 'status' replaced 'isEnabled', which was removed from the resource on 2026-10-01.
        # Falling back to it only where status is absent keeps an older tenant working without
        # letting a $null isEnabled quietly mean 'disabled'.
        $ruleStatus = if ("$($rule.status)") { [string] $rule.status }
                      elseif ($null -ne $rule.isEnabled) { if ($rule.isEnabled) { 'enabled' } else { 'disabled' } }
                      else { $null }

        if ($Status -and "$ruleStatus" -ne $Status) { continue }
        if ($ruleStatus -eq 'autoDisabled') { $autoDisabled++ }

        [PSCustomObject]@{
            PSTypeName       = 'MsecDefenderDetectionRule'
            Id               = [string] $rule.id
            DisplayName      = [string] $rule.displayName
            Description      = [string] $rule.description
            # 'enabled', 'disabled', or 'autoDisabled' - the last meaning Defender turned it
            # off because the query stopped working. $null only where the API returned neither
            # status nor the retired isEnabled, which is unknown rather than off.
            Status           = $ruleStatus
            IsRunning        = if ($null -eq $ruleStatus) { $null } else { $ruleStatus -eq 'enabled' }
            Frequency        = [string] $rule.schedule.frequency
            NextRun          = if ($rule.schedule.nextRunDateTime) { [datetime] $rule.schedule.nextRunDateTime } else { $null }
            # The alert this rule raises when it matches.
            AlertTitle       = [string] $rule.detectionAction.alertTemplate.title
            Severity         = [string] $rule.detectionAction.alertTemplate.severity
            Category         = [string] $rule.detectionAction.alertTemplate.category
            # The query is the rule. Kept whole so -Query can search it and so a reader can see
            # what is actually being watched without a second call.
            Query            = $queryText
            CreatedBy        = [string] $rule.createdBy
            CreatedUtc       = if ($rule.createdDateTime) { [datetime] $rule.createdDateTime } else { $null }
            LastModifiedBy   = [string] $rule.lastModifiedBy
            LastModifiedUtc  = if ($rule.lastModifiedDateTime) { [datetime] $rule.lastModifiedDateTime } else { $null }
            Raw              = $rule
        }
    }

    if ($autoDisabled) {
        Write-Warning "$autoDisabled custom detection rule(s) are AUTODISABLED - Defender switched them off because their query stopped working. They still appear in the portal's rule list and are not running. Filter on Status 'autoDisabled' to see them."
    }
}