Private/Private.ps1

function Add-Include {
    [CmdletBinding()]
    [OutputType([void])]
    param(
        [object[]]$Object,
        [System.Object]$Inputs,
        [System.Collections.Hashtable]$Index,
        [string]$Command
    )
    if ($Inputs.Include) {
        if (!$Object.id -and $Object -isnot [PSCustomObject]) {
            # Create array of [PSCustomObject] with 'id' property
            $Object = @($Object).foreach{ ,[PSCustomObject]@{ id = $_ }}
        } else {
            $Detailed = $true
        }
        if ($Index) {
            $Index.GetEnumerator().foreach{
                # Use 'Index' for 'Include' name and command to gather value(s) and append to output
                if ($Inputs.Include -contains $_.Key) {
                    if ($_.Key -eq 'members') {
                        foreach ($i in $Object) {
                            # Add 'members' by object
                            $SetParam = @{
                                Object = $i
                                Name = $_.Key
                                Value = if ($Detailed -eq $true) {
                                    & "$($_.Value)" -Id $i.id -Detailed -All -EA 0
                                } else {
                                    & "$($_.Value)" -Id $i.id -All -EA 0
                                }
                            }
                            Set-Property @SetParam
                        }
                    } else {
                        foreach ($i in (& "$($_.Value)" -Id $Object.id)) {
                            $SetParam = @{
                                Object = if ($i.policy_id) {
                                    $Object | Where-Object { $_.id -eq $i.policy_id }
                                } else {
                                    $Object | Where-Object { $_.id -eq $i.id }
                                }
                                Name = $_.Key
                                Value = $i
                            }
                            Set-Property @SetParam
                        }
                    }
                }
            }
        } elseif ($Command) {
            foreach ($i in (& $Command -Id $Object.id)) {
                @($Inputs.Include).foreach{
                    # Append all properties from 'Include'
                    $SetParam = @{
                        Object = if ($i.device_id) {
                            $Object | Where-Object { $_.id -eq $i.device_id }
                        } else {
                            $Object | Where-Object { $_.id -eq $i.id }
                        }
                        Name = $_
                        Value = $i.$_
                    }
                    Set-Property @SetParam
                }
            }
        }
    }
    return $Object
}
function Assert-Extension {
    [CmdletBinding()]
    [OutputType([string])]
    param([string]$Path,[string]$Extension)
    process {
        # Verify that 'Path' has a file extension matching 'Extension'
        if ($Path -and $Extension) {
            if ([System.IO.Path]::GetExtension($Path) -eq ".$Extension") {
                $Path
            } else {
                $Path,$Extension -join '.'
            }
        }
    }
}
function Build-Content {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param([System.Object]$Format,[System.Object]$Inputs)
    begin {
        function Build-Body ($Format,$Inputs) {
            $Body = @{}
            $Inputs.GetEnumerator().Where({ $Format.Body.Values -match $_.Key }).foreach{
                if ($_.Key -eq 'raw_array') {
                    $RawArray = @($_.Value)
                } else {
                    $Field = ($_.Key).ToLower()
                    $Value = if ($_.Value -is [string] -and $_.Value -eq 'null') {
                        # Convert [string] values of 'null' to null values
                        $null
                    } elseif ($_.Value -is [array]) {
                        # Convert [string] values of 'null' to null values
                        ,($_.Value).foreach{ if ($_ -is [string] -and $_ -eq 'null') { $null } else { $_ } }
                    } else {
                        $_.Value
                    }
                    if ($Field -eq 'body' -and ($Format.Body.root | Measure-Object).Count -eq 1) {
                        # Add 'body' value as [System.Net.Http.ByteArrayContent] when it's the only property
                        $FullFilePath = $Script:Falcon.Api.Path($_.Value)
                        $ByteStream = if ($PSVersionTable.PSVersion.Major -ge 6) {
                            Get-Content $FullFilePath -AsByteStream
                        } else {
                            Get-Content $FullFilePath -Encoding Byte -Raw
                        }
                        $ByteArray = [System.Net.Http.ByteArrayContent]::New($ByteStream)
                        $ByteArray.Headers.Add('Content-Type',$Headers.ContentType)
                    } else {
                        if (!$Body) { $Body = @{} }
                        if (($Value -is [array] -or $Value -is [string]) -and $Value |
                        Get-Member -MemberType Method | Where-Object { $_.Name -eq 'Normalize' }) {
                            # Normalize values to avoid Json conversion errors when 'Get-Content' was used
                            if ($Value -is [array]) {
                                $Value = [array] ($Value).Normalize()
                            } elseif ($Value -is [string]) {
                                $Value = ($Value).Normalize()
                            }
                        }
                        $Format.Body.GetEnumerator().Where({ $_.Value -eq $Field }).foreach{
                            if ($_.Key -eq 'root') {
                                # Add key/value pair directly to 'Body'
                                $Body.Add($Field,$Value)
                            } else {
                                # Create parent object and add key/value pair
                                if (!$Parents) { $Parents = @{} }
                                if (!$Parents.($_.Key)) { $Parents[$_.Key] = @{} }
                                $Parents.($_.Key).Add($Field,$Value)
                            }
                        }
                    }
                }
            }
            if ($ByteArray) {
                # Return 'ByteArray' object
                $ByteArray
            } elseif ($RawArray) {
                # Return 'RawArray' object and force [array]
                ,$RawArray
            } else {
                # Add parents as arrays in 'Body' and return 'Body' object
                if ($Parents) { $Parents.GetEnumerator().foreach{ $Body[$_.Key] = @($_.Value) }}
                if (($Body.Keys | Measure-Object).Count -gt 0) { $Body }
            }
        }
        function Build-Formdata ($Format,$Inputs) {
            $Formdata = @{}
            $Inputs.GetEnumerator().Where({ $Format.Formdata -contains $_.Key }).foreach{
                $Formdata[($_.Key).ToLower()] = if ($_.Key -eq 'content') {
                    $Content = try {
                        # Collect file content as a string
                        [string](Get-Content ($Script:Falcon.Api.Path($_.Value)) -Raw -EA 0)
                    } catch {
                        $null
                    }
                    # Supply original value if no file content is gathered
                    if ($Content) { $Content } else { $_.Value }
                } else {
                    $_.Value
                }
            }
            # Return 'Formdata' object
            if (($Formdata.Keys | Measure-Object).Count -gt 0) { $Formdata }
        }
        function Build-Query ($Format,$Inputs) {
            # Regex pattern for matching 'last [int] days/hours'
            [regex]$Relative = '([Ll]ast (?<Int>\d{1,}) ([Dd]ay[s]?|[Hh]our[s]?))'
            [array]$Query = foreach ($Field in $Format.Query.Where({ $Inputs.Keys -contains $_ })) {
                foreach ($Value in ($Inputs.GetEnumerator().Where({ $_.Key -eq $Field }).Value)) {
                    if ($Field -eq 'filter' -and $Value -match $Relative) {
                        # Convert 'last [int] days/hours' to Rfc3339
                        @($Value | Select-String $Relative -AllMatches).foreach{
                            foreach ($Match in $_.Matches.Value) {
                                [int]$Int = $Match -replace $Relative,'${Int}'
                                $Int = if ($Match -match 'day') { $Int * -24 } else { $Int * -1 }
                                $Value = $Value -replace $Match,(Convert-Rfc3339 $Int)
                            }
                        }
                    }
                    # Output array of strings to append to 'Path' and HTML-encode '+'
                    ,"$($Field)=$($Value -replace '\+','%2B')"
                }
            }
            # Return 'Query' array
            if ($Query) { $Query }
        }
    }
    process {
        if ($Inputs) {
            $Content = @{}
            @('Body','Formdata','Outfile','Query').foreach{
                if ($Format.$_) {
                    $Value = if ($_ -eq 'Outfile') {
                        # Get absolute path for 'OutFile'
                        $Outfile = $Inputs.GetEnumerator().Where({ $Format.Outfile -eq $_.Key }).Value
                        if ($Outfile) { $Script:Falcon.Api.Path($Outfile) }
                    } else {
                        # Get value(s) from each 'Build' function
                        & "Build-$_" -Format $Format -Inputs $Inputs
                    }
                    if ($Value) { $Content[$_] = $Value }
                }
            }
        }
    }
    end {
         # Return 'Content' table
        if (($Content.Keys | Measure-Object).Count -gt 0) { $Content }
    }
}
function Confirm-Parameter {
    [CmdletBinding()]
    [OutputType([boolean])]
    param(
        [Parameter(Mandatory)]
        [System.Object]$Object,
        [Parameter(Mandatory)]
        [string]$Command,
        [Parameter(Mandatory)]
        [string]$Endpoint,
        [string[]]$Required,
        [string[]]$Allowed,
        [string[]]$Content,
        [string[]]$Pattern,
        [System.Collections.Hashtable]$Format
    )
    begin {
        function Get-ValidPattern ([string]$Command,[string]$Endpoint,[string]$Parameter) {
            # Return 'ValidPattern' from parameter of a given command
            (Get-Command $Command).ParameterSets.Where({ $_.Name -eq $Endpoint }).Parameters.Where({
                $_.Name -eq $Parameter -or $_.Aliases -contains $Parameter }).Attributes.RegexPattern
        }
        function Get-ValidValues ([string]$Command,[string]$Endpoint,[string]$Parameter) {
            # Return 'ValidValues' from parameter of a given command
            (Get-Command $Command).ParameterSets.Where({ $_.Name -eq $Endpoint }).Parameters.Where({
                $_.Name -eq $Parameter -or $_.Aliases -contains $Parameter }).Attributes.ValidValues
        }
        # Create object string
        $ObjectString = ConvertTo-Json $Object -Depth 32 -Compress
    }
    process {
        if ($Object -is [System.Collections.Hashtable]) {
            @($Required).foreach{
                # Verify object contains required fields
                if ($Object.Keys -notcontains $_) { throw "Missing '$_'. $ObjectString" } else { $true }
            }
            if ($Allowed) {
                @($Object.Keys).foreach{
                    # Error if field is not in allowed list
                    if ($Allowed -notcontains $_) { throw "Unexpected '$_'. $ObjectString" } else { $true }
                }
            }
        } elseif ($Object -is [PSCustomObject]) {
            @($Required).foreach{
                # Verify object contains required fields
                if ($Object.PSObject.Members.Where({ $_.MemberType -eq 'NoteProperty' }).Name -notcontains $_) {
                    throw "Missing '$_'. $ObjectString"
                } else {
                    $true
                }
            }
            if ($Allowed) {
                @($Object.PSObject.Members.Where({ $_.MemberType -eq 'NoteProperty' }).Name).foreach{
                    # Error if field is not in allowed list
                    if ($Allowed -notcontains $_) { throw "Unexpected '$_'. $ObjectString" } else { $true }
                }
            }
        }
        @($Content).foreach{
            # Match property name with parameter name
            [string]$Parameter = if ($Format -and $Format.$_) { $Format.$_ } else { $_ }
            if ($Object.$_) {
                # Verify that 'ValidValues' contains provided value
                [string[]]$ValidValues = Get-ValidValues $Command $Endpoint $Parameter
                if ($Object.$_ -is [array]) {
                    foreach ($Item in $Object.$_) {
                        if ($ValidValues -notcontains $Item) { "'$Item' is not a valid '$_' value. $ObjectString" }
                    }
                } elseif ($ValidValues -notcontains $Object.$_) {
                    throw "'$($Object.$_)' is not a valid '$_' value. $ObjectString"
                }
            }
        }
        @($Pattern).foreach{
            # Match property name with parameter name
            [string]$Parameter = if ($Format -and $Format.$_) { $Format.$_ } else { $_ }
            if ($Object.$_) {
                # Verify provided value matches 'ValidPattern'
                $ValidPattern = Get-ValidPattern $Command $Endpoint $Parameter
                if ($Object.$_ -notmatch $ValidPattern) {
                    throw "'$($Object.$_)' is not a valid '$_' value. $ObjectString"
                }
            }
        }
    }
}
function Convert-Rfc3339 {
    [CmdletBinding()]
    [OutputType([string])]
    param([int32]$Hours)
    process {
        # Return Rfc3339 timestamp for $Hours from Get-Date
        "$([Xml.XmlConvert]::ToString(
            (Get-Date).AddHours($Hours),[Xml.XmlDateTimeSerializationMode]::Utc) -replace '\.\d+Z$','Z')"

    }
}
function Get-ContainerUrl {
    [CmdletBinding()]
    [OutputType([string])]
    param([switch]$Registry)
    process {
        if ($Registry) {
            # Output 'registry' URL using cached 'Hostname' value
            $Script:Falcon.Hostname -replace 'api(\.us-2|\.eu-1|laggar\.gcw)?','registry'
        } else {
            # Output 'container-upload' URL using cached 'Hostname' value
            if ($Script:Falcon.Hostname -match 'api\.crowdstrike') {
                $Script:Falcon.Hostname -replace 'api','container-upload.us-1'
            } else {
                $Script:Falcon.Hostname -replace 'api','container-upload'
            }
        }
    }
}
function Get-ParamSet {
    [CmdletBinding()]
    param(
        [string]$Endpoint,
        [System.Object]$Headers,
        [System.Object]$Inputs,
        [System.Object]$Format,
        [int32]$Max,
        [string]$HostUrl
    )
    begin {
        # Get baseline switch and endpoint parameters
        $Switches = @{}
        if ($Inputs) {
            $Inputs.GetEnumerator().Where({ $_.Key -match '^(All|Detailed|Total)$' }).foreach{
                $Switches.Add($_.Key,$_.Value)
            }
        }
        $Base = @{
            Path = if ($HostUrl) {
                $HostUrl,$Endpoint.Split(':',2)[0] -join $null
            } else {
                $Script:Falcon.Hostname,$Endpoint.Split(':',2)[0] -join $null
            }
            Method = $Endpoint.Split(':')[1]
            Headers = $Headers
        }
        if (!$Max) {
            $IdCount = if ($Inputs.ids) {
                # Find maximum number of 'ids' parameter using equivalent of 500 32-character ids
                $Pmax = ($Inputs.ids | Measure-Object -Maximum -Property Length -EA 0).Maximum
                if ($Pmax) { [Math]::Floor([decimal](18500/($Pmax + 5))) }
            }
            # Output maximum, no greater than 500
            $Max = if ($IdCount -and $IdCount -lt 500) { $IdCount } else { 500 }
        }
        # Get 'Content' from user input
        $Content = Build-Content -Inputs $Inputs -Format $Format
    }
    process {
        if ($Content.Query -and ($Content.Query | Measure-Object).Count -gt $Max) {
            Write-Verbose "[Get-ParamSet] Creating groups of $Max query values"
            for ($i = 0; $i -lt ($Content.Query | Measure-Object).Count; $i += $Max) {
                # Split 'Query' values into groups
                $Split = $Switches.Clone()
                $Split.Add('Endpoint',$Base.Clone())
                $Split.Endpoint.Path += if ($Split.Endpoint.Path -match '\?') {
                    "&$($Content.Query[$i..($i + ($Max - 1))] -join '&')"
                } else {
                    "?$($Content.Query[$i..($i + ($Max - 1))] -join '&')"
                }
                $Content.GetEnumerator().Where({ $_.Key -ne 'Query' -and $_.Value }).foreach{
                    # Add values other than 'Query'
                    $Split.Endpoint.Add($_.Key,$_.Value)
                }
                ,$Split
            }
        } elseif ($Content.Body -and ($Content.Body.ids | Measure-Object).Count -gt $Max) {
            Write-Verbose "[Get-ParamSet] Creating groups of $Max 'ids'"
            for ($i = 0; $i -lt ($Content.Body.ids | Measure-Object).Count; $i += $Max) {
                # Split 'Body' content into groups using 'ids'
                $Split = $Switches.Clone()
                $Split.Add('Endpoint',$Base.Clone())
                $Split.Endpoint.Add('Body',@{ ids = $Content.Body.ids[$i..($i + ($Max - 1))] })
                $Content.GetEnumerator().Where({ $_.Value }).foreach{
                    # Add values other than 'Body.ids'
                    if ($_.Key -eq 'Query') {
                        $Split.Endpoint.Path += if ($Split.Endpoint.Path -match '\?') {
                            "&$($_.Value -join '&')"
                        } else {
                            "?$($_.Value -join '&')"
                        }
                    } elseif ($_.Key -eq 'Body') {
                        ($_.Value).GetEnumerator().Where({ $_.Key -ne 'ids' }).foreach{
                            $Split.Endpoint.Body.Add($_.Key,$_.Value)
                        }
                    } else {
                        $Split.Endpoint.Add($_.Key,$_.Value)
                    }
                }
                ,$Split
            }
        } else {
            # Use base parameters, add content and output single parameter set
            $Switches.Add('Endpoint',$Base.Clone())
            if ($Content) {
                $Content.GetEnumerator().foreach{
                    if ($_.Key -eq 'Query') {
                        $Switches.Endpoint.Path += if ($Switches.Endpoint.Path -match '\?') {
                            "&$($_.Value -join '&')"
                        } else {
                            "?$($_.Value -join '&')"
                        }
                    } else {
                        $Switches.Endpoint.Add($_.Key,$_.Value)
                    }
                }
            }
            $Switches
        }
    }
}
function Get-RtrCommand {
    [CmdletBinding()]
    param(
        [string]$Command,
        [switch]$ConfirmCommand,
        [ValidateSet('ReadOnly','Responder','Admin')]
        [string]$Permission
    )
    begin {
        # Update 'Permission' to include lower level permission(s)
        [string[]]$Permission = switch ($Permission) {
            'ReadOnly' { 'ReadOnly' }
            'Responder' { 'ReadOnly','Responder' }
            'Admin' { 'ReadOnly','Responder','Admin' }
        }
    }
    process {
        # Create table of Real-time Response commands organized by permission level
        $Index = @{}
        @($null,'Responder','Admin').foreach{
            $Key = if ($_ -eq $null) { 'ReadOnly' } else { $_ }
            $Index[$Key] = (Get-Command "Invoke-Falcon$($_)Command").Parameters.GetEnumerator().Where({
                $_.Key -eq 'Command' }).Value.Attributes.ValidValues
        }
        # Filter 'Responder' and 'Admin' to unique command(s)
        $Index.Responder = $Index.Responder | Where-Object { $Index.ReadOnly -notcontains $_ }
        $Index.Admin = $Index.Admin | Where-Object { $Index.ReadOnly -notcontains $_ -and
            $Index.Responder -notcontains $_ }
        if ($Command) {
            # Determine command to invoke using $Command and permission level
            $Result = if ($Command -eq 'runscript') {
                # Force 'Admin' for 'runscript' command
                'Invoke-FalconAdminCommand'
            } else {
                $Index.GetEnumerator().Where({ $_.Value -contains $Command }).foreach{
                    if ($_.Key -eq 'ReadOnly') { 'Invoke-FalconCommand' } else { "Invoke-Falcon$($_.Key)Command" }
                }
            }
            if ($ConfirmCommand) { $Result -replace 'Invoke','Confirm' } else { $Result }
        } elseif ($Permission) {
            # Return available Real-time Response commands by permission
            $Index.GetEnumerator().Where({ $Permission -contains $_.Key }).Value
        } else {
            # Return all available Real-time Response commands
            @($Index.Values).foreach{ $_ }
        }
    }
}
function Get-RtrResult {
    [CmdletBinding()]
    param([object[]]$Object,[object[]]$Output)
    begin {
        # Real-time Response fields to capture from results
        $RtrFields = @('aid','batch_get_cmd_req_id','batch_id','cloud_request_id','complete','errors',
            'error_message','name','offline_queued','progress','queued_command_offline','session_id','sha256',
            'size','status','stderr','stdout','task_id')
    }
    process {
        foreach ($Result in ($Object | Select-Object $RtrFields)) {
            # Update 'Output' with populated result(s) from 'Object'
            @($Result.PSObject.Properties | Where-Object { $_.Value -or $_.Value -is [boolean] }).foreach{
                $Name = if ($_.Name -eq 'task_id') {
                    # Rename 'task_id' to 'cloud_request_id'
                    'cloud_request_id'
                } elseif ($_.Name -eq 'queued_command_offline') {
                    # Rename 'queued_command_offline' to 'offline_queued'
                    'offline_queued'
                } else {
                    $_.Name
                }
                $Value = if (($_.Value -is [object[]]) -and ($_.Value[0] -is [string])) {
                    # Convert array result into string
                    $_.Value -join ', '
                } elseif ($_.Value.code -and $_.Value.message) {
                    # Convert error code and message into string
                    (($_.Value).foreach{ "$($_.code): $($_.message)" }) -join ', '
                } else {
                    $_.Value
                }
                # Update 'Output' with result using 'aid' or 'session_id'
                $Match = if ($Result.aid) { 'aid' } else { 'session_id' }
                if ($Result.$Match) {
                    @($Output | Where-Object { $Result.$Match -eq $_.$Match }).foreach{
                        Set-Property $_ $Name $Value
                    }
                }
            }
        }
    }
    end { return $Output }
}
function Invoke-Falcon {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string]$Command,
        [string]$Endpoint,
        [System.Collections.Hashtable]$Headers,
        [System.Object]$Inputs,
        [System.Object]$Format,
        [switch]$RawOutput,
        [int32]$Max,
        [string]$HostUrl
    )
    begin {
        if (!$Script:Falcon.Api.Client.DefaultRequestHeaders.Authorization -or !$Script:Falcon.Hostname) {
            # Request initial authorization token
            Request-FalconToken
        }
        # Gather parameters for 'Get-ParamSet'
        $GetParam = @{}
        $PSBoundParameters.GetEnumerator().Where({ $_.Key -notmatch '^(Command|RawOutput)$' }).foreach{
            $GetParam.Add($_.Key,$_.Value)
        }
        # Add 'Accept: application/json' when undefined
        if (!$GetParam.Headers) { $GetParam.Add('Headers',@{}) }
        if (!$HostUrl -and !$GetParam.Headers.Accept) { $GetParam.Headers.Add('Accept','application/json') }
        if ($Format.Body -and !$GetParam.Headers.ContentType) {
            # Add 'ContentType: application/json' when undefined and 'Body' is present
            $GetParam.Headers.Add('ContentType','application/json')
        }
        if ($Format) {
            # Determine expected field values using 'Format'
            [System.Collections.Generic.List[string]]$Expected = @()
            @($Format.Values).foreach{
                if ($_ -is [array]) {
                    @($_).foreach{ $Expected.Add($_) }
                } elseif ($_.Keys) {
                    @($_.Values).foreach{ @($_).foreach{ $Expected.Add($_) }}
                }
            }
            if ($Expected) {
                @($Inputs.Keys).foreach{
                    if ($Expected -notcontains $_) {
                        # Create duplicate parameter using 'Alias' and remove original when expected
                        $Alias = ((Get-Command $Command).Parameters.$_.Aliases)[0]
                        if ($Alias -and $Expected -contains $Alias) {
                            $Inputs[$Alias] = $Inputs.$_
                            [void]$Inputs.Remove($_)
                        }
                    }
                }
            }
        }
        if ($Inputs.All -eq $true -and !$Inputs.Limit) {
            # Add maximum 'Limit' when not present and using 'All'
            $Limit = (Get-Command $Command).ParameterSets.Where({
                $_.Name -eq $Endpoint }).Parameters.Where({ $_.Name -eq 'Limit' }).Attributes.MaxRange
            if ($Limit) { $Inputs.Add('Limit',$Limit) }
        }
        # Regex for URL paths that don't need a secondary 'Detailed' request
        [regex]$NoDetail = '(/combined/|/rule-groups-full/)'
    }
    process {
        foreach ($Set in (Get-ParamSet @GetParam)) {
            [string]$Operation = $Set.Endpoint.Method.ToUpper()
            [string]$Target = New-ShouldMessage $Set.Endpoint
            try {
                # Refresh authorization token during loop
                if ($Script:Falcon.Expiration -le (Get-Date).AddSeconds(60)) { Request-FalconToken }
                if ($Set.Endpoint.Headers.ContentType -eq 'application/json' -and $Set.Endpoint.Body) {
                    # Convert body to Json
                    $Set.Endpoint.Body = ConvertTo-Json $Set.Endpoint.Body -Depth 32 -Compress
                }
                $Request = if ($PSCmdlet.ShouldProcess($Target,$Operation)) {
                    $Script:Falcon.Api.Invoke($Set.Endpoint)
                }
                if ($RawOutput) {
                    # Return result if 'RawOutput' is defined
                    $Request
                } elseif ($Set.Endpoint.Outfile -and (Test-Path $Set.Endpoint.Outfile)) {
                    # Display 'Outfile'
                    Get-ChildItem $Set.Endpoint.Outfile | Select-Object FullName,Length,LastWriteTime
                } elseif ($Request.Result.Content) {
                    # Capture pagination for 'Total' and 'All'
                    $Pagination = (ConvertFrom-Json (
                        $Request.Result.Content).ReadAsStringAsync().Result).meta.pagination
                    if ($Set.Total -eq $true -and $Pagination) {
                        # Output 'Total'
                        $Pagination.total
                    } else {
                        $Result = Write-Result $Request
                        if ($null -ne $Result) {
                            if ($Set.Detailed -eq $true -and $Set.Endpoint.Path -notmatch $NoDetail) {
                                # Output 'Detailed'
                                & $Command -Id $Result
                            } else {
                                # Output result
                                $Result
                            }
                            if ($Set.All -eq $true -and ($Result | Measure-Object).Count -lt
                            $Pagination.total) {
                                # Repeat request(s)
                                Invoke-Loop $Set $Pagination $Result
                            }
                        }
                    }
                }
            } catch {
                Write-Error $_
            }
        }
    }
}
function Invoke-Loop {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [System.Collections.Hashtable]$ParamSet,
        [Parameter(Mandatory)]
        [System.Object]$Pagination,
        [Parameter(Mandatory)]
        [System.Object]$Result
    )
    begin {
        # Regex for URL paths that don't need a secondary 'Detailed' request
        [regex]$NoDetail = '(/combined/|/rule-groups-full/)'
    }
    process {
        for ($i = ($Result | Measure-Object).Count; $Pagination.next_page -or $i -lt $Pagination.total;
        $i += ($Result | Measure-Object).Count) {
            Write-Verbose "[Invoke-Loop] $i of $($Pagination.total)"
            # Clone endpoint parameters and update pagination
            $Clone = $ParamSet.Clone()
            $Clone.Endpoint = $ParamSet.Endpoint.Clone()
            $Page = if ($Pagination.after) {
                @('after',$Pagination.after)
            } elseif ($Pagination.next_token) {
                @('next_token',$Pagination.next_token)
            } elseif ($Pagination.next_page) {
                @('offset',$Pagination.offset)
            } elseif ($Pagination.offset -match '^\d{1,}$') {
                @('offset',$i)
            } else {
                @('offset',$Pagination.offset)
            }
            $Clone.Endpoint.Path = if ($Clone.Endpoint.Path -match "$($Page[0])=\d{1,}") {
                # If offset was input, continue from that value
                $Current = [regex]::Match($Clone.Endpoint.Path,'offset=(\d+)(^&)?').Captures.Value
                $Page[1] += [int]$Current.Split('=')[-1]
                $Clone.Endpoint.Path -replace $Current,($Page -join '=')
            } elseif ($Clone.Endpoint.Path -match "$Endpoint^" -and $Clone.Endpoint.Path -notmatch '\?') {
                # Add pagination
                $Clone.Endpoint.Path,($Page -join '=') -join '?'
            } else {
                # Update pagination
                $Clone.Endpoint.Path,($Page -join '=') -join '&'
            }
            $Request = $Script:Falcon.Api.Invoke($Clone.Endpoint)
            if ($Request.Result.Content) {
                $Result = Write-Result $Request
                if ($null -ne $Result) {
                    if ($Clone.Detailed -eq $true -and $Clone.Endpoint.Path -notmatch $NoDetail) {
                        & $Command -Id $Result
                    } else {
                        $Result
                    }
                } else {
                    [string]$Message = "[Invoke-Loop] Results limited by API '$(($Clone.Endpoint.Path).Split(
                        '?')[0] -replace $Script:Falcon.Hostname,$null)' ($i of $($Pagination.total))."

                    Write-Error $Message
                }
                $Pagination = (ConvertFrom-Json (
                    $Request.Result.Content).ReadAsStringAsync().Result).meta.pagination
            }
        }
    }
}
function New-ShouldMessage {
    [CmdletBinding()]
    [OutputType([string[]])]
    param ([System.Collections.Hashtable]$Object)
    process {
        try {
            $Output = [PSCustomObject]@{}
            if ($Object.Path) {
                [string]$Path = $Object.Path
                if ($Path -match $Script:Falcon.Hostname) {
                    # Add 'Hostname' when using cached hostname value
                    Set-Property $Output Hostname $Script:Falcon.Hostname
                    $Path = $Path -replace $Script:Falcon.Hostname,$null
                }
                if ($Path -match '\?') {
                    # Add 'Path' without query values
                    [string[]]$Array = $Path -split '\?'
                    [string[]]$Query = $Array[-1] -split '&'
                    Set-Property $Output Path $Array[0]
                } else {
                    Set-Property $Output Path $Path
                }
            }
            if ($Object.Headers) {
                # Add 'Headers' value
                Set-Property $Output Headers ($Object.Headers.GetEnumerator().foreach{
                    $_.Key,$_.Value -join '=' } -join ', ')
            }
            if ($Query) {
                # Add 'Query' value as an array
                Set-Property $Output Query $Query
            }
            foreach ($Pair in $Object.GetEnumerator().Where({ $_.Key -ne '^(Headers|Method|Path)$' })) {
                [string]$Value = switch ($Pair.Key) {
                    'Body' {
                        # Convert 'Body' to Json
                        $Pair.Value | ConvertTo-Json -Depth 8
                    }
                }
                if ($Value) { Set-Property $Output $Pair.Key $Value }
            }
            "`r`n",($Output | Format-List | Out-String).Trim(),"`r`n" -join "`r`n"
        } catch {}
    }
}
function Set-Property {
    [CmdletBinding()]
    [OutputType([void])]
    param([System.Object]$Object,[string]$Name,[System.Object]$Value)
    process {
        if ($Object.$Name) {
            # Update existing property
            $Object.$Name = $Value
        } else {
            # Add property to [PSCustomObject]
            $Object.PSObject.Properties.Add((New-Object PSNoteProperty($Name,$Value)))
        }
    }
}
function Test-FqlStatement {
    [CmdletBinding()]
    [OutputType([boolean])]
    param(
        [Parameter(Mandatory)]
        [string]$String
    )
    begin {
        $Pattern = [regex]("(?<FqlProperty>[\w\.]+):(?<FqlOperator>(!~?|~|(>|<)=?|\*)?)" +
            "(?<FqlValue>[\w\d\s\.\-\*\[\]\\,'`":]+)")
    }
    process {
        if ($String -notmatch $Pattern) {
            # Error when 'filter' does not match $Pattern
            throw "'$String' is not a valid Falcon Query Language statement."
        } else {
            $true
        }
    }
}
function Test-OutFile {
    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param([string]$Path)
    process {
        if (!$Path) {
            @{
                # Generate parameters for 'Write-Error' if 'Path' is not present
                Message = "Missing required parameter 'Path'."
                Category = [System.Management.Automation.ErrorCategory]::ObjectNotFound
            }
        } elseif ($Path -is [string] -and ![string]::IsNullOrEmpty($Path) -and (Test-Path $Path) -eq $true) {
            @{
                # Generate parameters for 'Write-Error' if 'Path' already exists
                Message = "An item with the specified name $Path already exists."
                Category = [System.Management.Automation.ErrorCategory]::WriteError
                TargetName = $Path
            }
        }
    }
}
function Test-RegexValue {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)]
        [string]$String
    )
    begin {
        $RegEx = @{
            md5    = [regex]'^[A-Fa-f0-9]{32}$'
            sha256 = [regex]'^[A-Fa-f0-9]{64}$'
            ipv4   = [regex]'^([1-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.'
            ipv6   = [regex]'^[0-9a-fA-F]{1,4}:'
            domain = [regex]'^(https?://)?((?=[a-z0-9-]{1,63}\.)(xn--)?[a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,63}$'
            email  = [regex]"^\w+([-+.']\w+)*@\w+([-.]\w+)*\.\w+([-.]\w+)*$"
            tag    = [regex]'^[-\w\d_/]+$'
        }
    }
    process {
        $Output = ($RegEx.GetEnumerator()).foreach{
            if ($String -match $_.Value) {
                if ($_.Key -match '^(ipv4|ipv6)$') {
                    # Use initial RegEx match, then validate IP and return type
                    if (($String -as [System.Net.IPAddress] -as [bool]) -eq $true) { $_.Key }
                } else {
                    # Return type
                    $_.Key
                }
            }
        }
    }
    end {
        if ($Output) {
            Write-Verbose "[Test-RegexValue] $(@($Output,$String) -join ': ')"
            $Output
        }
    }
}
function Write-Result {
    [CmdletBinding()]
    param([System.Object]$Request)
    begin {
        function Write-Meta ($Object) {
            # Convert [array] and [PSCustomObject] into a flat Verbose output message
            function arr ($Array,$Output,$String) {
                @($Array).foreach{
                    if ($_.GetType().Name -eq 'PSCustomObject') {
                        obj $_ $Output $String
                    } else {
                        $Output[$String] = $_ -join ','
                    }
                }
            }
            function obj ($Object,$Output,$String) {
                $Object.PSObject.Members.Where({ $_.MemberType -eq 'NoteProperty' }).foreach{
                    $Name = if ($String) { @($String,$_.Name) -join '.' } else { $_.Name }
                    if ($_.Value.GetType().Name -eq 'PSCustomObject') {
                        obj $_.Value $Output $Name
                    } elseif ($_.Value.GetType().Name -eq 'Object[]') {
                        arr $_.Value $Output $Name
                    } else {
                        $Output[$Name] = $_.Value -join ','
                    }
                }
            }
            $Output = @{}
            @($Object).Where({ $_.GetType().Name -eq 'PSCustomObject' }).foreach{ obj $_ $Output }
            if ($Output) {
                Write-Verbose "[Write-Result] $($Output.GetEnumerator().foreach{ @((@('meta',$_.Key) -join '.'),
                    $_.Value) -join '=' } -join ', ')"

            }
        }
    }
    process {
        # Capture result content
        $Result = if ($Request.Result.Content) { ($Request.Result.Content).ReadAsStringAsync().Result }
        [string]$TraceId = if ($Request.Result.Headers) {
            # Capture trace_id for error messages
            $Request.Result.Headers.GetEnumerator().Where({ $_.Key -eq 'X-Cs-Traceid' }).Value
        }
        # Convert content to Json
        $Json = if ($Result -and $Request.Result.Content.Headers.ContentType -eq 'application/json' -or
        $Request.Result.Content.Headers.ContentType.MediaType -eq 'application/json') {
            ConvertFrom-Json $Result
        }
        if ($Json) {
            # Gather field names from result, excluding 'errors', 'extensions', and 'meta'
            [string[]]$ResponseFields = @($Json.PSObject.Properties).Where({ $_.Name -notmatch
                '^(errors|extensions|meta)$' -and $_.Value }).foreach{ $_.Name }
            # Write verbose 'meta' output
            if ($Json.meta) { Write-Meta $Json.meta }
            if ($ResponseFields) {
                if (($ResponseFields | Measure-Object).Count -gt 1) {
                    # Output all fields by name
                    $Json | Select-Object $ResponseFields
                } elseif ($ResponseFields -eq 'combined' -and $Json.$ResponseFields.PSObject.Properties.Name -eq
                'resources' -and ($Json.$ResponseFields.PSObject.Properties.Name | Measure-Object).Count -eq 1) {
                    # Output values under 'combined.resources'
                    $Json.$ResponseFields.resources.PSObject.Properties.Value
                } elseif ($ResponseFields -eq 'resources' -and $Json.$ResponseFields.PSObject.Properties.Name -eq
                'events' -and ($Json.$ResponseFields.PSObject.Properties.Name | Measure-Object).Count -eq 1) {
                    # Output 'resources.events'
                    $Json.$ResponseFields.events
                } else {
                    # Output single field
                    $Json.$ResponseFields
                }
            } elseif ($Json.meta) {
                # Output 'meta' fields when nothing else is available
                [string[]]$MetaFields = @($Json.meta.PSObject.Properties).Where({ $_.Name -notmatch
                    '^(entity|pagination|powered_by|query_time|trace_id)$' }).foreach{ $_.Name }
                if ($MetaFields) { $Json.meta | Select-Object $MetaFields }
            }
            @($Json.PSObject.Properties).Where({ $_.Name -eq 'errors' -and $_.Value }).foreach{
                # Output error
                $Message = ConvertTo-Json $_.Value -Compress
                $PSCmdlet.WriteError(
                    [System.Management.Automation.ErrorRecord]::New(
                        [Exception]::New($Message),
                        $TraceId,
                        [System.Management.Automation.ErrorCategory]::InvalidResult,
                        $Request
                    )
                )
            }
        } else {
            # Output non-Json content
            $Result
        }
        # Check for rate limiting
        Wait-RetryAfter $Request
    }
}
function Wait-RetryAfter {
    [CmdletBinding()]
    param([System.Object]$Request)
    process {
        if ($Request.Result.StatusCode -and $Request.Result.StatusCode.GetHashCode() -eq 429 -and
        $Request.Result.RequestMessage.RequestUri.AbsolutePath -ne '/oauth2/token') {
            # Convert 'X-Ratelimit-Retryafter' value to seconds and wait
            $Wait = [System.DateTimeOffset]::FromUnixTimeSeconds(($Request.Result.Headers.GetEnumerator().Where({
                $_.Key -eq 'X-Ratelimit-Retryafter' }).Value)).Second
            Write-Verbose "[Wait-RetryAfter] Rate limited for $Wait seconds..."
            Start-Sleep -Seconds $Wait
        }
    }
    end { if ($Request) { $Request.Dispose() }}
}