public/Invoke-MtGraphSecurityQuery.ps1

<#
.SYNOPSIS
  Execute KQL query in Microsoft 365 Defender Advanced Hunting by using Graph API Security endpoint to get results programmatically.

.DESCRIPTION
  This cmdlet allows you to execute KQL queries against the Microsoft 365 Defender Advanced Hunting API.
  It simplifies the process of querying and retrieving data from the Microsoft Defender XDR for integration of Maester checks.

.EXAMPLE
  Invoke-MtGraphSecurityQuery -Query "IdentityInfo | where isnotempty(PrivilegedEntraPimRoles)" -Timespan "P14D"

  # Get identities with eligible Entra roles of the last 14 days

.LINK
    https://maester.dev/docs/commands/Invoke-MtGraphSecurityQuery
#>

function Invoke-MtGraphSecurityQuery {
    [CmdletBinding()]
    param(
        # Valid KQL query
        [Parameter(Mandatory = $true, Position = 0, ValueFromPipeline = $true)]
        [string] $Query,
        # Lookback/timespan for KQL query in ISO 8601 duration, e.g. P14D, PT6H, P2DT3H
        [Parameter(Mandatory = $false)]
        [ValidatePattern('^P(?=.+)(\d+Y)?(\d+M)?(\d+D)?(T(\d+H)?(\d+M)?(\d+S)?)?$')]
        [ValidateScript({
            try { [System.Xml.XmlConvert]::ToTimeSpan($_) | Out-Null; $true }
            catch { throw "Timespan must be ISO 8601 duration (e.g., P14D, PT6H, P2DT3H)." }
        })]
        [string] $Timespan = "P14D"
    )

    process {
        $Body = @{
            "Query" = $Query;
            "Timespan" = $Timespan;
        } | ConvertTo-Json

        $sleepDuration = 1
        $retry = $false
        $retryCount = 0
        $maxRetries = 3

        do {
            try {
                $retry = $false
                $QueryResponse = (Invoke-MtGraphRequest -ApiVersion "beta" -RelativeUri "security/runHuntingQuery" -Method POST -Body $Body -OutputType PSObject -ErrorVariable QueryError)
                $QueryResults = $QueryResponse.Results
            }
            catch {
                if ($_.Exception.Response.StatusCode.value__ -ne 429) {
                    $retry = $false
                    if ($QueryError[0].Message -match '{.*}$') {
                        $ErrorDetailsJson = ($QueryError[0].Message -split '\r?\n\r?\n', 2)[-1].Trim()   # grab content after the first empty line
                        $KqlQueryExecutionError = ($ErrorDetailsJson | ConvertFrom-Json).error.message
                        throw $KqlQueryExecutionError
                    }
                    throw $_
                    return
                } else {
                    $retry = $true
                    $retryCount++
                    Write-Verbose "API returned 429, retrying in $sleepDuration seconds (Attempt $retryCount of $maxRetries)"
                    Start-Sleep -Seconds $sleepDuration
                }
            }
        } until (-not $retry -or $retryCount -ge $maxRetries)

        if ( $QueryResults ) {
            # Convert JSON strings to objects
            $propertiesToConvert = ($QueryResponse.schema | Where-Object {$_.type -eq "Object"}).Name
            foreach ($item in $QueryResults) {
                foreach ($prop in $propertiesToConvert) {
                    if (![string]::IsNullOrWhiteSpace($item.$prop)) {
                        try {
                            $item.$prop = $item.$prop | ConvertFrom-Json -Depth 10
                        }
                        catch {
                            Write-Verbose "Failed to convert property $($item.$prop) on $($prop) for item with ID '$($item.Id)': $_"
                        }

                    }
                }
            }
        return $QueryResults
        }
    }
}