Private/Private.ps1

function Add-Property {
    [CmdletBinding()]
    param(
        [object] $Object,
        [string] $Name,
        [object] $Value
    )
    process {
        # Add property to [PSCustomObject]
        $Object.PSObject.Properties.Add((New-Object PSNoteProperty($Name, $Value)))
    }
}
function Build-Content {
    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [object] $Format,
        [object] $Inputs
    )
    begin {
        function Build-Body ($Format, $Inputs) {
            $Body = @{}
            $Inputs.GetEnumerator().Where({ $Format.Body.Values -match $_.Key }).foreach{
                $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]) {
                    ,($_.Value).foreach{
                        if ($_ -is [string] -and $_ -eq 'null') {
                            # Convert [string] values of 'null' to null values
                            $null
                        } else {
                            $_
                        }
                    }
                } else {
                    $_.Value
                }
                if ($Field -eq 'body') {
                    # Add 'body' value as [System.Net.Http.ByteArrayContent]
                    $FullFilePath = $Script:Falcon.Api.Path($_.Value)
                    Write-Verbose "[Build-Body] '$FullFilePath'"
                    $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
            } else {
                if ($Parents) {
                    $Parents.GetEnumerator().foreach{
                        # Add parents as arrays in 'Body'
                        $Body[$_.Key] = @( $_.Value )
                    }
                }
                if (($Body.Keys | Measure-Object).Count -gt 0) {
                    # Return 'Body' object
                    Write-Verbose "[Build-Body] $(ConvertTo-Json -InputObject $Body -Depth 32 -Compress)"
                    $Body
                }
            }
        }
        function Build-Formdata ($Format, $Inputs) {
            $Formdata = @{}
            $Inputs.GetEnumerator().Where({ $Format.Formdata -contains $_.Key }).foreach{
                $Formdata[($_.Key).ToLower()] = if ($_.Key -eq 'content') {
                    # Collect file content as a string
                    [string] (Get-Content ($Script:Falcon.Api.Path($_.Value)) -Raw)
                } else {
                    $_.Value
                }
            }
            if (($Formdata.Keys | Measure-Object).Count -gt 0) {
                # Return 'Formdata' object
                Write-Verbose "[Build-Formdata] $(ConvertTo-Json -InputObject $Formdata -Depth 32 -Compress)"
                $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')"
                }
            }
            if ($Query) {
                # Return 'Query' array
                $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 {
        if (($Content.Keys | Measure-Object).Count -gt 0) {
            # Return 'Content' table
            $Content
        }
    }
}
function Confirm-Parameter {
    [CmdletBinding()]
    [OutputType([boolean])]
    param(
        [Parameter(Mandatory = $true)]
        [object] $Object,

        [Parameter(Mandatory = $true)]
        [string] $Command,

        [Parameter(Mandatory = $true)]
        [string] $Endpoint,

        [Parameter()]
        [array] $Required,

        [Parameter()]
        [array] $Allowed,

        [Parameter()]
        [array] $Content,

        [Parameter()]
        [array] $Pattern,

        [Parameter()]
        [object] $Format
    )
    begin {
        function Get-ValidPattern ($Command, $Endpoint, $Parameter) {
            # Return 'ValidPattern' from parameter of a given command
            (Get-Command $Command).ParameterSets.Where({ $_.Name -eq $Endpoint }).Parameters.Where({
                $_.Name -eq $Parameter }).Attributes.RegexPattern
        }
        function Get-ValidValues ($Command, $Endpoint, $Parameter) {
            # Return 'ValidValues' from parameter of a given command
            (Get-Command $Command).ParameterSets.Where({ $_.Name -eq $Endpoint }).Parameters.Where({
                $_.Name -eq $Parameter }).Attributes.ValidValues
        }
        # Create object string
        $ObjectString = ConvertTo-Json -InputObject $Object -Depth 32 -Compress
    }
    process {
        if ($Object -is [hashtable]) {
            ($Required).foreach{
                # Verify object contains required fields
                if ($Object.Keys -notcontains $_) {
                    throw "Missing '$_'. $ObjectString"
                } else {
                    $true
                }
            }
            if ($Allowed) {
                ($Object.Keys).foreach{
                    if ($Allowed -notcontains $_) {
                        # Error if field is not in allowed list
                        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{
                    if ($Allowed -notcontains $_) {
                        # Error if field is not in allowed list
                        throw "Unexpected '$_'. $ObjectString"
                    } else {
                        $true
                    }
                }
            }
        }
        ($Content).foreach{
            $Parameter = if ($Format -and $Format.$_) {
                # Match property name with parameter name
                $Format.$_
            } else {
                $_
            }
            if ($Object.$_) {
                # Verify that 'ValidValues' contains provided value
                $ValidValues = Get-ValidValues -Command $Command -Endpoint $Endpoint -Parameter $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{
            $Parameter = if ($Format -and $Format.$_) {
                # Match property name with parameter name
                $Format.$_
            } else {
                $_
            }
            if ($Object.$_) {
                # Verify provided value matches 'ValidPattern'
                $ValidPattern = Get-ValidPattern -Command $Command -Endpoint $Endpoint -Parameter $Parameter
                if ($Object.$_ -notmatch $ValidPattern) {
                    throw "'$($Object.$_)' is not a valid '$_' value. $ObjectString"
                }
            }
        }
    }
}
function Convert-Rfc3339 {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [int] $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-ParamSet {
    [CmdletBinding()]
    param(
        [string] $Endpoint,
        [object] $Headers,
        [object] $Inputs,
        [object] $Format,
        [int] $Max
    )
    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    = "$($Script:Falcon.Hostname)$($Endpoint.Split(':')[0])"
            Method  = $Endpoint.Split(':')[1]
            Headers = $Headers
        }
        if (!$Max) {
            $IdCount = if ($Inputs.ids) {
                # Find maximum number of 'ids' using equivalent of 500 32-character ids
                [Math]::Floor([decimal](18500/(($Inputs.ids |
                    Measure-Object -Maximum -Property Length).Maximum + 5)))
            }
            $Max = if ($IdCount -and $IdCount -lt 500) {
                # Output maximum, no greater than 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 "[Build-Param] 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 += "?$($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 "[Build-Param] 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 += "?$($_.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 += "?$($_.Value -join '&')"
                    } else {
                        $Switches.Endpoint.Add($_.Key, $_.Value)
                    }
                }
            }
            $Switches
        }
    }
}
function Get-RtrCommand {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [string] $Command,
        [switch] $ConfirmCommand
    )
    process {
        # Determine command to invoke using $Command and permission level
        $Result = if ($Command -eq 'runscript') {
            # Force 'Admin' for 'runscript' command
            'Invoke-FalconAdminCommand'
        } else {
            # Create table of Real-time Response commands organized by permission level
            $Commands = @{}
            @($null, 'Responder', 'Admin').foreach{
                $Key = if ($_ -eq $null) {
                    'ReadOnly'
                } else {
                    $_
                }
                $Commands[$Key] = (Get-Command "Invoke-Falcon$($_)Command").Parameters.GetEnumerator().Where({
                    $_.Key -eq 'Command' }).Value.Attributes.ValidValues
            }
            # Filter 'Responder' and 'Admin' to unique command(s)
            $Commands.Responder = $Commands.Responder | Where-Object { $Commands.ReadOnly -notcontains $_ }
            $Commands.Admin = $Commands.Admin | Where-Object { $Commands.ReadOnly -notcontains $_ -and
                $Commands.Responder -notcontains $_ }
            $Commands.GetEnumerator().Where({ $_.Value -contains $Command }).foreach{
                if ($_.Key -eq 'ReadOnly') {
                    'Invoke-FalconCommand'
                } else {
                    "Invoke-Falcon$($_.Key)Command"
                }
            }
        }
    }
    end {
        if ($PSBoundParameters.ConfirmCommand) {
            # Output 'Confirm' command
            $Result -replace 'Invoke', 'Confirm'
        } else {
            $Result
        }
    }
}
function Get-RtrResult {
    [CmdletBinding()]
    param(
        [object] $Object,
        [object] $Output
    )
    begin {
        # Real-time Response fields to capture from results
        $RtrFields = @('aid', 'complete', 'errors', 'offline_queued', 'session_id', 'stderr', 'stdout', 'task_id')
    }
    process {
        # Update $Output with results from $Object
        foreach ($Result in ($Object | Select-Object $RtrFields)) {
            @($Result.PSObject.Properties | Where-Object { $_.Value }).foreach{
                $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
                }
                $Name = if ($_.Name -eq 'task_id') {
                    # Rename 'task_id' to 'cloud_request_id'
                    'cloud_request_id'
                } else {
                    $_.Name
                }
                @($Output | Where-Object { $_.aid -eq $Result.aid }).foreach{
                    $_.$Name = $Value
                }
            }
        }
    }
    end {
        return $Output
    }
}
function Invoke-Falcon {
    [CmdletBinding()]
    param(
        [string] $Command,
        [string] $Endpoint,
        [object] $Headers,
        [object] $Inputs,
        [object] $Format,
        [switch] $RawOutput,
        [int] $Max
    )
    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)
        }
        if (!$GetParam.Headers) {
            $GetParam.Add('Headers', @{})
        }
        if (!$GetParam.Headers.Accept) {
            # Add 'Accept: application/json' when undefined
            $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 ($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 ($ParamSet in (Get-ParamSet @GetParam)) {
            try {
                if ($Script:Falcon.Expiration -le (Get-Date).AddSeconds(60)) {
                    # Refresh authorization token during loop
                    Request-FalconToken
                }
                if ($ParamSet.Endpoint.Body -and $ParamSet.Endpoint.Headers.ContentType -eq 'application/json') {
                    # Convert body to Json
                    $ParamSet.Endpoint.Body = ConvertTo-Json $ParamSet.Endpoint.Body -Depth 32 -Compress
                }
                $Request = $Script:Falcon.Api.Invoke($ParamSet.Endpoint)
                if ($RawOutput) {
                    # Return result if 'RawOutput' is defined
                    $Request
                } elseif ($ParamSet.Endpoint.Outfile -and (Test-Path $ParamSet.Endpoint.Outfile)) {
                    # Display 'Outfile'
                    Get-ChildItem $ParamSet.Endpoint.Outfile
                } elseif ($Request.Result.Content) {
                    # Capture pagination for 'Total' and 'All'
                    $Pagination = (ConvertFrom-Json (
                        $Request.Result.Content).ReadAsStringAsync().Result).meta.pagination
                    if ($ParamSet.Total -eq $true -and $Pagination) {
                        # Output 'Total'
                        $Pagination.total
                    } else {
                        $Result = Write-Result -Request $Request
                        if ($null -ne $Result) {
                            if ($ParamSet.Detailed -eq $true -and $ParamSet.Endpoint.Path -notmatch $NoDetail) {
                                # Output 'Detailed'
                                & $Command -Ids $Result
                            } else {
                                # Output result
                                $Result
                            }
                            if ($ParamSet.All -eq $true -and ($Result | Measure-Object).Count -lt
                            $Pagination.total) {
                                # Repeat request(s)
                                $Param = @{
                                    ParamSet   = $ParamSet
                                    Pagination = $Pagination
                                    Result     = $Result
                                }
                                Invoke-Loop @Param
                            }
                        }
                    }
                }
            } catch {
                Write-Error $_
            }
        }
    }
}
function Invoke-Loop {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object] $ParamSet,

        [Parameter(Mandatory = $true)]
        [object] $Pagination,

        [Parameter(Mandatory = $true)]
        [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^") {
                # Add pagination
                "$($Clone.Endpoint.Path)?$($Page -join '=')"
            } else {
                # Update pagination
                "$($Clone.Endpoint.Path)&$($Page -join '=')"
            }
            $Request = $Script:Falcon.Api.Invoke($Clone.Endpoint)
            if ($Request.Result.Content) {
                $Result = Write-Result -Request $Request
                if ($null -ne $Result) {
                    if ($Clone.Detailed -eq $true -and $Clone.Endpoint.Path -notmatch $NoDetail) {
                        & $Command -Ids $Result
                    } else {
                        $Result
                    }
                } else {
                    $ErrorMessage = ("[Invoke-Loop] Results limited by API " +
                        "'$(($Clone.Endpoint.Path).Split('?')[0] -replace $Script:Falcon.Hostname, $null)' " +
                        "($i of $($Pagination.total)).")
                    Write-Error $ErrorMessage
                }
                $Pagination = (ConvertFrom-Json (
                    $Request.Result.Content).ReadAsStringAsync().Result).meta.pagination
            }
        }
    }
}
function Test-FqlStatement {
    [CmdletBinding()]
    [OutputType([boolean])]
    param(
        [Parameter(Mandatory = $true, Position = 1)]
        [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-RegexValue {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory = $true, Position = 1)]
        [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] '^((?=[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)$') {
                    if (($String -as [System.Net.IPAddress] -as [bool]) -eq $true) {
                        # Use initial RegEx match, then validate IP and return type
                        $_.Key
                    }
                } else {
                    # Return type
                    $_.Key
                }
            }
        }
    }
    end {
        if ($Output) {
            Write-Verbose "[Test-RegexValue] $($Output): $String"
            $Output
        }
    }
}
function Update-FieldName {
    [CmdletBinding()]
    param(
        [object] $Fields,
        [object] $Inputs
    )
    process {
        # Update user input field names for API submission
        if ($Fields.Keys -and $Inputs.Keys) {
            ($Fields.Keys).foreach{
                if ($Inputs.$_ -or $Inputs.$_ -is [boolean]) {
                    $Inputs["$($Fields.$_)"] = $Inputs.$_
                    [void] $Inputs.Remove($_)
                }
            }
        }
        $Inputs
    }
}
function Write-Result {
    [CmdletBinding()]
    param(
        [object] $Request
    )
    process {
        $Result = if ($Request.Result.Content) {
            # Capture result content
            ($Request.Result.Content).ReadAsStringAsync().Result
        }
        $Json = if ($Result -and $Request.Result.Content.Headers.ContentType -eq 'application/json' -or
        $Request.Result.Content.Headers.ContentType.MediaType -eq 'application/json') {
            # Convert content to Json
            ConvertFrom-Json -InputObject $Result
        }
        $Verbose = if ($Request.Result.Headers) {
            # Capture trace_id and add response header to verbose output
            $TraceId = $Request.Result.Headers.GetEnumerator().Where({ $_.Key -eq 'X-Cs-Traceid' }).Value
            ($Request.Result.Headers.GetEnumerator().foreach{
                ,"$($_.Key)=$($_.Value)"
            })
        }
        if ($Verbose) {
            Write-Verbose "[Write-Result] $($Verbose -join ',')"
        }
        if ($Json) {
            # Gather field names from result, excluding 'errors', 'extensions', and 'meta'
            $ResponseFields = @($Json.PSObject.Properties).Where({
                $_.Name -notmatch '^(errors|extensions|meta)$' -and $_.Value
            }).foreach{
                $_.Name
            }
            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) {
                $MetaFields = ($Json.meta.PSObject.Properties).Where({ $_.Name -notmatch
                '^(entity|pagination|powered_by|query_time|trace_id)$' }).foreach{
                    # Output relevant 'meta' fields
                    $_.Name
                }
                if ($MetaFields) {
                    # Output 'meta' result(s)
                    $Json.meta | Select-Object $MetaFields
                }
            }
            @($Json.PSObject.Properties).Where({ $_.Name -eq 'errors' -and $_.Value }).foreach{
                # Output error
                $Message = ConvertTo-Json -InputObject $_.Value -Compress
                $PSCmdlet.WriteError(
                    [System.Management.Automation.ErrorRecord]::New(
                        [Exception]::New($Message),
                        $TraceId,
                        [System.Management.Automation.ErrorCategory]::NotSpecified,
                        $Request
                    )
                )
            }
        } else {
            $Result
        }
        # Check for rate limiting
        Wait-RetryAfter $Request
    }
}
function Wait-RetryAfter {
    [CmdletBinding()]
    param(
        [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()
        }
    }
}