Private/Get-DefenderXDR.ps1

function Get-DefenderXDR {
    <#
    .SYNOPSIS
        Queries Defender XDR for custom detection rules and streaming configuration.
        Requires -IncludeDefenderXDR flag. Uses delegated Microsoft Graph auth with
        CustomDetection.Read.All (or CustomDetection.ReadWrite.All) scope. Falls back
        to an Az access-token REST call if delegated Graph auth is unavailable.
    .OUTPUTS
        PSCustomObject with custom detection rules and XDR table analysis.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][PSCustomObject]$Context
    )

    function ConvertTo-PlainTextToken {
        param([Parameter(Mandatory)]$AccessToken)

        if ($AccessToken -is [string]) {
            return $AccessToken
        }

        if ($AccessToken -is [securestring]) {
            $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($AccessToken)
            try {
                return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)
            }
            finally {
                if ($bstr -ne [IntPtr]::Zero) {
                    [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)
                }
            }
        }

        return [string]$AccessToken
    }

    # Fetch custom detection rules.
    # Prefer delegated user context via Microsoft Graph PowerShell for CustomDetection.Read.All.
    $customRules = @()
    $fetched = $false
    $endpoints = @(
        'https://graph.microsoft.com/beta/security/rules/detectionRules',
        'https://graph.microsoft.com/v1.0/security/rules/detectionRules'
    )

    $mgCmd = Get-Command Invoke-MgGraphRequest -ErrorAction SilentlyContinue
    if ($mgCmd) {
        try {
            $requiredScopes = @('CustomDetection.Read.All', 'CustomDetection.ReadWrite.All')
            $mgContext = Get-MgContext -ErrorAction SilentlyContinue
            $hasRequiredScope = $false

            if ($mgContext -and $mgContext.Scopes) {
                $hasRequiredScope = @($mgContext.Scopes | Where-Object { $_ -in $requiredScopes }).Count -gt 0
            }

            if (-not $hasRequiredScope) {
                $connectParams = @{
                    Scopes       = @('CustomDetection.Read.All')
                    ContextScope = 'Process'
                    NoWelcome    = $true
                }
                if ($Context.PSObject.Properties.Name -contains 'TenantId' -and -not [string]::IsNullOrWhiteSpace($Context.TenantId)) {
                    $connectParams.TenantId = $Context.TenantId
                }

                Connect-MgGraph @connectParams -ErrorAction Stop | Out-Null
                $mgContext = Get-MgContext -ErrorAction SilentlyContinue
                $hasRequiredScope = $mgContext -and $mgContext.Scopes -and (@($mgContext.Scopes | Where-Object { $_ -in $requiredScopes }).Count -gt 0)
            }

            if ($hasRequiredScope) {
                foreach ($endpoint in $endpoints) {
                    try {
                        $uri = $endpoint
                        do {
                            $response = Invoke-MgGraphRequest -Method GET -Uri $uri -OutputType PSObject -ErrorAction Stop
                            if ($response -and $response.PSObject.Properties.Name -contains 'value') {
                                $customRules += @($response.value)
                            }

                            if ($response -and $response.PSObject.Properties.Name -contains '@odata.nextLink' -and -not [string]::IsNullOrWhiteSpace($response.'@odata.nextLink')) {
                                $uri = $response.'@odata.nextLink'
                            }
                            else {
                                $uri = $null
                            }
                        } while ($uri)

                        $fetched = $true
                        Write-Verbose "Fetched Defender custom detection rules using delegated Graph user context (${endpoint})."
                        break
                    }
                    catch {
                        Write-Verbose "Delegated Graph request failed for ${endpoint}: $_"
                    }
                }
            }
            else {
                Write-Warning 'Defender XDR retrieval could not establish delegated Microsoft Graph scope CustomDetection.Read.All.'
            }
        }
        catch {
            Write-Verbose "Delegated Graph auth/request path failed: $_"
        }
    }

    # Fallback: if delegated Graph auth/request did not fetch results, try Az token + raw REST.
    # This keeps delegated Graph as the preferred path while still supporting non-interactive/CI environments.
    if (-not $fetched) {
        $graphToken = $null
        try {
            $tokenResult = $null
            if ($Context.PSObject.Properties.Name -contains 'TenantId' -and -not [string]::IsNullOrWhiteSpace($Context.TenantId)) {
                $tokenResult = Get-AzAccessToken -ResourceUrl 'https://graph.microsoft.com' -TenantId $Context.TenantId -ErrorAction Stop
            }
            else {
                $tokenResult = Get-AzAccessToken -ResourceUrl 'https://graph.microsoft.com' -ErrorAction Stop
            }
            $graphToken = ConvertTo-PlainTextToken -AccessToken $tokenResult.Token
        }
        catch {
            Write-Warning 'Cannot acquire Microsoft Graph token. Defender XDR analysis will be skipped.'
            return $null
        }

        $headers = @{
            Authorization  = "Bearer $graphToken"
            'Content-Type' = 'application/json'
        }

        foreach ($endpoint in $endpoints) {
            try {
                $uri = $endpoint
                do {
                    $response = Invoke-AzRestWithRetry -Uri $uri -Headers $headers
                    if ($response -and $response.PSObject.Properties.Name -contains 'value') {
                        $customRules += @($response.value)
                    }

                    if ($response -and $response.PSObject.Properties.Name -contains '@odata.nextLink' -and -not [string]::IsNullOrWhiteSpace($response.'@odata.nextLink')) {
                        $uri = $response.'@odata.nextLink'
                    }
                    else {
                        $uri = $null
                    }
                } while ($uri)

                $fetched = $true
                break
            }
            catch {
                Write-Verbose "Could not fetch Defender custom detection rules from ${endpoint}: $_"
            }
        }
    }

    if (-not $fetched) {
        Write-Warning 'Could not fetch Defender custom detection rules from Graph API (beta/v1.0).'
        return [PSCustomObject]@{
            CustomRules      = @()
            TotalXDRRules    = 0
            XDRTableCoverage = @{}
            KnownXDRTables   = @(
                'DeviceInfo', 'DeviceNetworkInfo', 'DeviceProcessEvents',
                'DeviceNetworkEvents', 'DeviceFileEvents', 'DeviceRegistryEvents',
                'DeviceLogonEvents', 'DeviceImageLoadEvents', 'DeviceEvents',
                'DeviceFileCertificateInfo', 'EmailAttachmentInfo', 'EmailEvents',
                'EmailPostDeliveryEvents', 'EmailUrlInfo', 'UrlClickEvents',
                'IdentityDirectoryEvents', 'IdentityLogonEvents', 'IdentityQueryEvents',
                'CloudAppEvents', 'AlertInfo', 'AlertEvidence'
            )
        }
    }

    # Parse XDR rule queries for table references
    $xdrTableCoverage = @{}
    foreach ($rule in $customRules) {
        $query = $null
        if ($rule.PSObject.Properties.Name -contains 'queryCondition' -and $rule.queryCondition) {
            $query = $rule.queryCondition.queryText
        }
        if (-not $query -and
            $rule.PSObject.Properties.Name -contains 'detectionAction' -and
            $rule.detectionAction -and
            $rule.detectionAction.PSObject.Properties.Name -contains 'queryCondition' -and
            $rule.detectionAction.queryCondition) {
            $query = $rule.detectionAction.queryCondition.queryText
        }
        if ($query) {
            $tables = Get-TablesFromKql -Kql $query
            foreach ($t in $tables) {
                if (-not $xdrTableCoverage.ContainsKey($t)) { $xdrTableCoverage[$t] = 0 }
                $xdrTableCoverage[$t]++
            }
        }
    }

    # Known Defender XDR advanced hunting tables (reference list).
    # A table is only "streaming" if it actually exists in Sentinel as an Analytics-tier table.
    $knownXDRTables = @(
        'DeviceInfo', 'DeviceNetworkInfo', 'DeviceProcessEvents',
        'DeviceNetworkEvents', 'DeviceFileEvents', 'DeviceRegistryEvents',
        'DeviceLogonEvents', 'DeviceImageLoadEvents', 'DeviceEvents',
        'DeviceFileCertificateInfo', 'EmailAttachmentInfo', 'EmailEvents',
        'EmailPostDeliveryEvents', 'EmailUrlInfo', 'UrlClickEvents',
        'IdentityDirectoryEvents', 'IdentityLogonEvents', 'IdentityQueryEvents',
        'CloudAppEvents', 'AlertInfo', 'AlertEvidence'
    )

    [PSCustomObject]@{
        CustomRules      = $customRules
        TotalXDRRules    = $customRules.Count
        XDRTableCoverage = $xdrTableCoverage
        KnownXDRTables   = $knownXDRTables
    }
}