CortexQuery.psm1

<#
.SYNOPSIS
    Module for running XQL-queries vs. Palo Alto Cortex/XSIAM API using PowerShell.
 
.DESCRIPTION
    The module takes nessasary input with 'Set-CortexAuthHeader'. The header, along with an XQL-query is then requested to start vs. Cortex API.
    The scripts then polls the results, waiting for the query to be executed, before presenting the results directly, or parsing raw-bytestream into if more than 1000 results are returned.
 
.AUTHOR
    Erlend Westervik
 
.COPYRIGHT
    None
 
.LICENSE
    None
 
.VERSION
    1.0.0
 
.NOTES
    - Works in PowerShell Core and Windows PowerShell (5.1)
    - API-reference, auth: https://docs-cortex.paloaltonetworks.com/r/Cortex-XDR-REST-API/API-Reference
    - API-reference, start XQL-query: https://docs-cortex.paloaltonetworks.com/r/Cortex-XDR-REST-API/Start-an-XQL-Query
 
.EXAMPLE
    Prepare the context for the API connection and auth
 
    $keyid = '99' #API key-ID
    $fqdn = 'https://api-[CUSTOMER ID].xdr.eu.paloaltonetworks.com' #API URL
    $key = '[Your 128 char long API key]' #API key/secret
 
    Set-CortexAuthHeader -keyId $keyid -fqdn $fqdn -key $key
 
.EXAMPLE
    Run a query to get events from the last hour, with detailed output of every step to the console.
    Then extract the 'raw_log'-property and convert it from JSON
 
    $query = @'
        dataset = cloud_audit_logs
        | filter log_name = "azure_ad_signin_logs"
        | limit 1
    '@
 
    $results = Invoke-CortexQuery -Query $query -relativeTime 1h -VerboseLogging
    $results.raw_log | ConvertFrom-Json
 
#>


Function Write-Console {
    param(
        [ValidateSet(0, 1, 2, 3, 4)]
        [int]$Level = 0,

        [Parameter(Mandatory=$true)]
        [string]$Message
    )
    $Message = $Message.Replace("`r",'').Replace("`n",' ')
    switch ($Level) {
        0 { $Status = 'Info'        ;$FGColor = 'White'   }
        1 { $Status = 'Success'     ;$FGColor = 'Green'   }
        2 { $Status = 'Warning'     ;$FGColor = 'Yellow'  }
        3 { $Status = 'Error'       ;$FGColor = 'Red'     }
        4 { $Status = 'Debug'       ;$FGColor = 'Gray'    }
        Default { $Status = ''      ;$FGColor = 'Black'   }
    }
    if ($VerboseLogging) {
        Write-Host "$((Get-Date).ToString()) " -ForegroundColor 'DarkGray' -NoNewline
        Write-Host "$Status" -ForegroundColor $FGColor -NoNewline

        if ($level -eq 4) {
            Write-Host ("`t " + $Message) -ForegroundColor 'Cyan'
        }
        else {
            Write-Host ("`t " + $Message) -ForegroundColor 'White'
        }
    }
}

Function Set-CortexAuthHeader {
    param(
        [parameter(mandatory=$true)][int]$keyId,
        [parameter(mandatory=$true)][string]$key,
        [parameter(mandatory=$true)][string]$fqdn
    )
    
    if ($fqdn -notmatch 'paloaltonetworks.com') {
        Write-Warning "Set-CortexAuthHeader - The FQDN '$fqdn' does not contain 'paloaltonetworks.com'"
    }

    # Prepare auth-header with API Key and Key Id for functions that do API-calls
    $script:headers = @{
        "x-xdr-auth-id" = $keyID
        "Authorization" = $key
        "Content-Type" = "application/json"
        "Accept-Encoding" = "gzip"
    }

    # Make FQDN avaliable for other cmdlets inside module to use.
    $script:fqdn = [string]$fqdn
}

Function Test-CortexAuthHeader {
    if (!$script:headers -or !$script:fqdn) {
        Write-Console -Level 2 -Message "Test-CortexAuthHeader - Header and required parameters is not set. Please run 'Set-CortexAuthHeader'"
    }
    else {
        Write-Console -Level 1 -Message "Test-CortexAuthHeader - Header and FQDN seems ok."
    }
}

Function Invoke-CortexQuery {
    [CmdletBinding()]
    param (
        $Query,
        $relativeTime = '1d',
        $resultLimit = 100,
        [switch]$VerboseLogging
    )

    # Test to see if auth context is avaliable. Simple tests, just to see that there are values, not verifying that the key length is 128-bits etc.
    Test-CortexAuthHeader

    # Function for converting relative time into unix timstamps. No need for re-use, so I'm keeping it inside this function.
    Function Get-RelativeUnixTimestamp {
        param(
            [Parameter(Mandatory = $true)]
            [string]$TimeString
        )

        # Regular expression to match format like 1d, 2h, 15m
        if ($TimeString -match '^(\d+)([dhm])$') {
            $value = [int]$matches[1]
            $unit  = $matches[2]
    
            switch ($unit) {
                'd' { $milliseconds = $value * 24 * 60 * 60 * 1000 }
                'h' { $milliseconds = $value * 60 * 60 * 1000 }
                'm' { $milliseconds = $value * 60 * 1000 }
                default {
                    Write-Console -Level 2 -Message "Unsupported time unit: $unit"
                }
            }
            Write-Console -Level 0 -Message "Converted from relative time string to ms. '$TimeString' -> '$milliseconds'"
            return @{ relativeTime = $milliseconds }
        }
        else {
            Write-Console -Level 2 -Message "Invalid time format. Use formats like '1d', '2h', '15m'."
        }
    }

    $Collection = (Get-RelativeUnixTimestamp -TimeString $relativeTime).Values
    [long]$relativeTime = $Collection.GetEnumerator() | Select-Object -First 1

    # Prepare a XQL query request
    $requestData = @{
        request_data = @{
            query = $query
            timeframe = @{
                relativeTime = [long]$relativeTime
            }
        }
    }

    # Convert the request to JSON
    $jsonRequest = $requestData | ConvertTo-Json -Depth 3

    # Build URL for "Start an XQL Query"
    $apiName = "xql"
    $callName = "start_xql_query"
    $url = "$Script:fqdn/public_api/v1/$apiName/$callName/"

    Write-Console -Level 0 -Message "Start XQL-query - Endpoint: '$Script:fqdn/public_api/v1/'"

    # Send the request (POST)
    try {
        $responseStart = Invoke-WebRequest -Uri $url -Method Post -Headers $Script:headers -Body $jsonRequest
        if ($responseStart.StatusCode -eq 200) {
            Write-Console -Level 1 -Message "Start XQL-query - Call name '$callName'. Statuscode: $($responseStart.StatusCode)"
        }
        else {
            Write-Console -Level 3 -Message "Start XQL-query - Call name '$callName'. Unexpected response. Statuscode: $($responseStart.StatusCode)"
            Return
        }
    }
    catch {
        Write-Console -Level 3 -Message "Start XQL-query - Failed to do call '$callName'. Error: $($_.Exception.Message)"
    }

    # Get the ID of the queued query
    $script:QueryId = ($responseStart.Content | ConvertFrom-Json).Reply
    Write-Console -Level 0 -Message "Start XQL-query - Got query id from response ($script:QueryId)"    
    Invoke-CortexQueryResults -QueryId $script:QueryId -limit $resultLimit
}

Function Invoke-CortexQueryResults {
    [CmdletBinding()]
    param (
        [string]$QueryId,
        [int]$Limit = 2000
    )

    # Prepare request for query results
    $RequestData = @{
        request_data = @{
            query_id = "$QueryId"
            pending_flag = $True #True (default): The call returns immediately with status PENDING / False: The API will block until query completes and results are ready to be returned.
            limit = $Limit
            format = "json"
        }
    }

    # Convert the request to JSON
    $JSONRequest = $RequestData | ConvertTo-Json -Depth 3

    # Build URL for "Get XQL Query Result"
    $apiName = "xql"
    $callName = "get_query_results"
    $url = "$Script:fqdn/public_api/v1/$apiName/$callName/"

    # Send the request (POST)
    try {
        $responseResults = Invoke-WebRequest -Uri $url -Method Post -Headers $Script:headers -Body $JSONRequest -ErrorAction Stop
        $responseResultsStatus = $(($responseResults.content | ConvertFrom-Json).Reply.Status)
        Write-Console -Level 1 -Message "Get XQL-query results - Invoke-WebRequest"
    }
    catch {
        Write-Console -Level 3 -Message "Get XQL-query results - Invoke-WebRequest: Error $($_.Exception.Message)"
    }

    # Wait for it to be done, if not already done
    While ($responseResultsStatus -ne 'SUCCESS') {
        Start-Sleep -Seconds 2
        $responseResults = Invoke-WebRequest -Uri $url -Method Post -Headers $Script:headers -Body $JSONRequest
        $responseResultsStatus = $(($responseResults.content | ConvertFrom-Json).Reply.Status)
        Write-Console -Level 0 -Message "Get XQL-query results - Invoke-WebRequest: Status is '$responseResultsStatus' (waiting 2s)"
        if ($responseResultsStatus -eq 'FAIL') {
            Write-Console -Level 3 -Message "Get XQL-query results - Aborting. Query failed:"
            $responseResults | Format-List *
            Break
        }
    }

    # Output results as PSObject
    $convertedResponseResults = $responseResults.content | ConvertFrom-Json
    $resultCount = ($convertedResponseResults.reply.results.data).count

    # See if we got any results-data
    if ($resultCount -ge 1) {

        # If we get data, this means it was less than 1k results
        if ($convertedResponseResults.reply.results.data) {
            Write-Console -Level 0 -Message "Get XQL-query results - $resultCount results returned"
            Return $convertedResponseResults.reply.results.data
        }

        # If there the resultsize is bigger than 1k, we get a stream id in response, and have to fetch a bytestream instead to get all results
        elseif ($convertedResponseResults.reply.results.stream_id) {
            Write-Console -Level 0 -Message "More than 1k results. Making query vs. stream (stream_id: $($convertedResponseResults.reply.results.stream_id))"
            Write-Host $convertedResponseResults.reply.results.stream_id -ForegroundColor Cyan
            Invoke-CortexQueryResultsStream -StreamID $convertedResponseResults.reply.results.stream_id
        }
    }
    else {
        Write-Console -Level 0 -Message "No results returned"
        Return $null
    }
}

Function Invoke-CortexQueryResultsStream {
    [CmdletBinding()]
    param (
        [string]$StreamID
    )

    # Prepare request for query results
    $RequestData = @{
        request_data = @{
            stream_id = "$StreamID"
            is_gzip_compressed = $false
        }
    }

    # Convert the request to JSON
    $JSONRequest = $RequestData | ConvertTo-Json -Depth 3

    # Build URL for "Get XQL Query Result Stream"
    $apiName = "xql"
    $callName = "get_query_results_stream"
    $url = "$Script:fqdn/public_api/v1/$apiName/$callName/"

    # Send the request (POST)
    try {
        $responseResults = Invoke-WebRequest -Uri $url -Method Post -Headers $Script:headers -Body $JSONRequest -ErrorAction Stop        
        Write-Console -Level 1 -Message "Get XQL-query results stream - Invoke-WebRequest: Status code $($responseResults.StatusCode)"
    }
    catch {
        Write-Console -Level 3 -Message "Get XQL-query results stream - Invoke-WebRequest: Error $($_.Exception.Message)"
    }

    # Output results as PSObject, after encoding bytestream and converting it from JSON.
    if ($responseResults.StatusCode -eq 200) {        
        $rawBytes = $responseResults.Content

        # If it's actually a string, convert to bytes:
        if (-not ($rawBytes -is [byte[]])) {
            $rawBytes = [System.Text.Encoding]::UTF8.GetBytes($rawBytes)
        }

        # Convert the raw bytes to a UTF-8 strings
        $jsonLines = [System.Text.Encoding]::UTF8.GetString($rawBytes)

        # Newline-delimited JSON objects are splitted and then converted to PSObjects
        $Objects = $jsonLines -split "`n" | ForEach-Object {
            $_ | ConvertFrom-Json
        }
        Write-Console -Level 1 -Message "Get XQL-query results stream - Process: $($Objects.count) JSON-objects converted to PS-objects."
        Return $Objects
    }
    else {
        Write-Console -Level 3 -Message "Get XQL-query results stream - Invoke-WebRequest: Status code $($responseResults.StatusCode)"
        Return $null
    }
}

# The other functions are support functions and does not need to be called directly.
Export-ModuleMember -Function 'Set-CortexAuthHeader', 'Invoke-CortexQuery'