Public/psf-real-time-response.ps1

function Get-FalconQueue {
<#
.SYNOPSIS
Create a report of Real-time Response commands in the offline queue
.DESCRIPTION
Creates a CSV of pending Real-time Response commands and their related session information. By default, sessions
within the offline queue expire 7 days after creation. Sessions can have additional commands appended to them to
extend their expiration time.
 
Additional host information can be appended to the results using the 'Include' parameter.
 
Requires 'Real Time Response: Read', 'Real Time Response: Write' and 'Real Time Response (Admin): Write'.
.PARAMETER Days
Days worth of results to retrieve [default: 7]
.PARAMETER Include
Include additional properties
.LINK
https://github.com/crowdstrike/psfalcon/wiki/Get-FalconQueue
#>

    [CmdletBinding()]
    param(
        [Parameter(Position=1)]
        [int32]$Days,
        [Parameter(Position=2)]
        [ValidateSet('agent_version','cid','external_ip','first_seen','host_hidden_status','hostname',
            'last_seen','local_ip','mac_address','os_build','os_version','platform_name','product_type',
            'product_type_desc','reduced_functionality_mode','serial_number','system_manufacturer',
            'system_product_name','tags',IgnoreCase=$false)]
        [string[]]$Include
    )
    begin {
        $Days = if ($PSBoundParameters.Days) { $PSBoundParameters.Days } else { 7 }
        # Properties to capture from request results
        $Select = @{
            Session = @('aid','user_id','user_uuid','id','created_at','deleted_at','status')
            Command = @('stdout','stderr','complete')
        }
        # Define output path
        $Csv = Join-Path (Get-Location).Path "FalconQueue_$(Get-Date -Format FileDateTime).csv"
    }
    process {
        try {
            $SessionParam = @{
                Filter = "(deleted_at:null+commands_queued:1),(created_at:>'last $Days days'+commands_queued:1)"
                Detailed = $true
                All = $true
            }
            $Sessions = Get-FalconSession @SessionParam | Select-Object id,device_id
            if (-not $Sessions) { throw "No queued Real-time Response sessions available." }
            Write-Host "[Get-FalconQueue] Found $(($Sessions | Measure-Object).Count) queued sessions..."
            [object[]]$HostInfo = if ($Include) {
                # Capture host information for eventual output
                $Sessions.device_id | Get-FalconHost | Select-Object @($Include + 'device_id')
            }
            foreach ($Session in ($Sessions.id | Get-FalconSession -Queue)) {
                Write-Host "[Get-FalconQueue] Retrieved command detail for $($Session.id)..."
                @($Session.Commands).foreach{
                    # Create output for each individual command in queued session
                    $Obj = [PSCustomObject]@{}
                    @($Session | Select-Object $Select.Session).foreach{
                        @($_.PSObject.Properties).foreach{
                            # Add session properties with 'session' prefix
                            $Name = if ($_.Name -match '^(id|(created|deleted|updated)_at|status)$') {
                                "session_$($_.Name)"
                            } else {
                                $_.Name
                            }
                            Set-Property $Obj $Name $_.Value
                        }
                    }
                    @($_.PSObject.Properties).foreach{
                        # Add command properties
                        $Name = if ($_.Name -match '^((created|deleted|updated)_at|status)$') {
                            "command_$($_.Name)"
                        } else {
                            $_.Name
                        }
                        Set-Property $Obj $Name $_.Value
                    }
                    if ($Obj.command_status -eq 'FINISHED') {
                        # Update command properties with results
                        Write-Host "[Get-FalconQueue] Retrieving command result for cloud_request_id '$(
                            $Obj.cloud_request_id)'..."

                        $ConfirmCmd = Get-RtrCommand $Obj.base_command -ConfirmCommand
                        @($Obj.cloud_request_id | & $ConfirmCmd -EA 4 | Select-Object $Select.Command).foreach{
                            @($_.PSObject.Properties).foreach{ Set-Property $Obj "command_$($_.Name)" $_.Value }
                        }
                    } else {
                        @('command_complete','command_stdout','command_stderr').foreach{
                            # Add empty command output
                            $Value = if ($_ -eq 'command_complete') { $false } else { $null }
                            Set-Property $Obj $_ $Value
                        }
                    }
                    if ($Include -and $HostInfo) {
                        @($HostInfo.Where({ $_.device_id -eq $Obj.aid })).foreach{
                            @($_.PSObject.Properties.Where({ $_.Name -ne 'device_id' })).foreach{
                                # Add 'Include' properties
                                Set-Property $Obj $_.Name $_.Value
                            }
                        }
                    }
                    try { $Obj | Export-Csv $Csv -NoTypeInformation -Append } catch { $Obj }
                }
            }
        } catch {
            throw $_
        } finally {
            if (Test-Path $Csv) { Get-ChildItem $Csv | Select-Object FullName,Length,LastWriteTime }
        }
    }
}
function Invoke-FalconDeploy {
<#
.SYNOPSIS
Deploy and run an executable using Real-time Response
.DESCRIPTION
'Put' files will be checked for identical file names, and if any are found, the Sha256 hash values will be
compared between your local and cloud files. If they are different, a prompt will appear asking which file to use.
 
After ensuring that the 'Put' file is available, a Real-time Response session will be started for the designated
host(s) (or members of the Host Group), 'mkdir' will create a folder ('FalconDeploy_<FileDateTime>') within the
appropriate temporary folder (\Windows\Temp or /tmp), 'cd' will navigate to the new folder, and the target file or
archive will be 'put' into that folder. If the target is an archive, it will be extracted, and the designated
'Run' file will be executed. If the target is a file, it will be 'run'.
 
Details of each step will be output to a CSV file in your current directory.
 
Requires 'Hosts: Read', 'Real Time Response (Admin): Write'.
.PARAMETER File
Name of a 'CloudFile' or path to a local executable to upload
.PARAMETER Archive
Name of a 'CloudFile' or path to a local archive (zip, tar, tar.gz, tgz) to upload
.PARAMETER Run
Name of the file to run once extracted from the target archive
.PARAMETER Argument
Arguments to include when running the target executable
.PARAMETER Timeout
Length of time to wait for a result, in seconds
.PARAMETER QueueOffline
Add non-responsive Hosts to the offline queue
.PARAMETER Include
Include additional properties
.PARAMETER GroupId
Host group identifier
.PARAMETER HostId
Host identifier
.LINK
https://github.com/crowdstrike/psfalcon/wiki/Invoke-FalconDeploy
#>

    [CmdletBinding(DefaultParameterSetName='HostId_File',SupportsShouldProcess)]
    param(
        [Parameter(ParameterSetName='HostId_File',Mandatory,Position=1)]
        [Parameter(ParameterSetName='GroupId_File',Mandatory,Position=1)]
        [ValidateScript({
            if (Test-Path $_ -PathType Leaf) {
                $true
            } else {
                $FileName = [System.IO.Path]::GetFileName($_)
                if (Get-FalconPutFile -Filter "name:['$FileName']") {
                    $true
                } else {
                    throw "Cannot find path '$_' because it does not exist or is a directory."
                }
            }
        })]
        [Alias('Path','FullName')]
        [string]$File,
        [Parameter(ParameterSetName='HostId_Archive',Mandatory)]
        [Parameter(ParameterSetName='GroupId_Archive',Mandatory)]
        [ValidateScript({
            if ($_ -match '\.(zip|tar(.gz)?|tgz)$') {
                if (Test-Path $_ -PathType Leaf) {
                    $true
                } else {
                    $FileName = [System.IO.Path]::GetFileName($_)
                    if (Get-FalconPutFile -Filter "name:['$FileName']") {
                        $true
                    } else {
                        throw "Cannot find path '$_' because it does not exist or is a directory."
                    }
                }
            } else {
                throw "'$_' does not match expected file extension: 'zip', 'tar', 'tar.gz', or 'tgz'."
            }
        })]
        [string]$Archive,
        [Parameter(ParameterSetName='HostId_Archive',Mandatory,Position=2)]
        [Parameter(ParameterSetName='GroupId_Archive',Mandatory,Position=2)]
        [string]$Run,
        [Parameter(ParameterSetName='HostId_File',Position=2)]
        [Parameter(ParameterSetName='GroupId_File',Position=2)]
        [Parameter(ParameterSetName='HostId_Archive',Position=3)]
        [Parameter(ParameterSetName='GroupId_Archive',Position=3)]
        [Alias('Arguments')]
        [string]$Argument,
        [Parameter(ParameterSetName='HostId_File',Position=3)]
        [Parameter(ParameterSetName='GroupId_File',Position=3)]
        [Parameter(ParameterSetName='HostId_Archive',Position=4)]
        [Parameter(ParameterSetName='GroupId_Archive',Position=4)]
        [ValidateRange(30,600)]
        [int32]$Timeout,
        [Parameter(ParameterSetName='HostId_File',Position=4)]
        [Parameter(ParameterSetName='GroupId_File',Position=4)]
        [Parameter(ParameterSetName='HostId_Archive',Position=5)]
        [Parameter(ParameterSetName='GroupId_Archive',Position=5)]
        [boolean]$QueueOffline,
        [Parameter(ParameterSetName='HostId_File',Position=5)]
        [Parameter(ParameterSetName='GroupId_File',Position=5)]
        [Parameter(ParameterSetName='HostId_Archive',Position=6)]
        [Parameter(ParameterSetName='GroupId_Archive',Position=6)]
        [ValidateSet('agent_version','cid','external_ip','first_seen','hostname','last_seen','local_ip',
            'mac_address','os_build','os_version','platform_name','product_type','product_type_desc',
            'serial_number','system_manufacturer','system_product_name','tags',IgnoreCase=$false)]
        [string[]]$Include,
        [Parameter(ParameterSetName='GroupId_File',Mandatory,ValueFromPipelineByPropertyName)]
        [Parameter(ParameterSetName='GroupId_Archive',Mandatory,ValueFromPipelineByPropertyName)]
        [ValidatePattern('^[a-fA-F0-9]{32}$')]
        [Alias('id')]
        [string]$GroupId,
        [Parameter(ParameterSetName='HostId_File',Mandatory,ValueFromPipelineByPropertyName,ValueFromPipeline)]
        [Parameter(ParameterSetName='HostId_Archive',Mandatory,ValueFromPipelineByPropertyName,ValueFromPipeline)]
        [ValidatePattern('^[a-fA-F0-9]{32}$')]
        [Alias('HostIds','device_id','host_ids','aid')]
        [string[]]$HostId
    )
    begin {
        # Define output file, temporary folder, file detail and archive expansion/chmod scripts
        [string]$DeployName = "FalconDeploy_$(Get-Date -Format FileDateTime)"
        [string]$Csv = Join-Path (Get-Location).Path "$DeployName.csv"
        [string]$FilePath = if ($Archive) {
            $Script:Falcon.Api.Path($Archive)
        } else {
            $Script:Falcon.Api.Path($File)
        }
        [string]$PutFile = [System.IO.Path]::GetFileName($FilePath)
        [string]$RunFile = if ($File) { $PutFile } else { $Run }
        function Update-CloudFile ([string]$FileName,[string]$FilePath) {
            # Fields to collect from 'Put' files list
            $Fields = @('id','name','created_timestamp','modified_timestamp','sha256')
            try {
                # Compare 'CloudFile' and 'LocalFile'
                Write-Host "[Invoke-FalconDeploy] Checking cloud for existing file..."
                $CloudFile = @(Get-FalconPutFile -Filter "name:['$FileName']" -Detailed |
                Select-Object $Fields).foreach{
                    [PSCustomObject]@{
                        id = $_.id
                        name = $_.name
                        created_timestamp = [datetime]$_.created_timestamp
                        modified_timestamp = [datetime]$_.modified_timestamp
                        sha256 = $_.sha256
                    }
                }
                $LocalFile = @(Get-ChildItem $FilePath | Select-Object CreationTime,Name,LastWriteTime).foreach{
                    [PSCustomObject]@{
                        name = $_.Name
                        created_timestamp = $_.CreationTime
                        modified_timestamp = $_.LastWriteTime
                        sha256 = ((Get-FileHash $FilePath).Hash).ToLower()
                    }
                }
                if ($LocalFile -and $CloudFile) {
                    if ($LocalFile.sha256 -eq $CloudFile.sha256) {
                        Write-Host "[Invoke-FalconDeploy] Matched hash values between local and cloud files."
                    } else {
                        # Prompt for file choice and remove 'CloudFile' if 'LocalFile' is chosen
                        Write-Host "[CloudFile]"
                        $CloudFile | Select-Object name,created_timestamp,modified_timestamp,sha256 |
                            Format-List | Out-Host
                        Write-Host "[LocalFile]"
                        $LocalFile | Select-Object name,created_timestamp,modified_timestamp,sha256 |
                            Format-List | Out-Host
                        $FileChoice = $host.UI.PromptForChoice(
                            "[Invoke-FalconDeploy] '$FileName' exists in your 'Put' Files. Use existing version?",
                            $null,[System.Management.Automation.Host.ChoiceDescription[]]@("&Yes","&No"),0)
                        if ($FileChoice -eq 0) {
                            Write-Host "[Invoke-FalconDeploy] Proceeding with CloudFile '$($CloudFile.id)'..."
                        } else {
                            [System.Object]$RemovePut = $CloudFile.id | Remove-FalconPutFile
                            if ($RemovePut.writes.resources_affected -eq 1) {
                                Write-Host "[Invoke-FalconDeploy] Removed CloudFile '$($CloudFile.id)'."
                            }
                        }
                    }
                }
            } catch {
                throw $_
            }
            if ($RemovePut.writes.resources_affected -eq 1 -or !$CloudFile) {
                # Upload 'LocalFile' and output result
                Write-Host "[Invoke-FalconDeploy] Uploading '$FileName'..."
                $Param = @{
                    Path = $FilePath
                    Name = $FileName
                    Description = "Invoke-FalconDeploy [$((Show-FalconModule).UserAgent)]"
                    Comment = "Invoke-FalconDeploy [$((Show-FalconModule).UserAgent)]"
                }
                $AddPut = Send-FalconPutFile @Param
                if (!$AddPut) {
                    throw "Upload failed."
                } elseif ($AddPut -and $AddPut.writes.resources_affected -eq 1) {
                    Write-Host "[Invoke-FalconDeploy] Upload complete."
                }
            }
        }
        function Write-RtrResult ([object[]]$Object,[string]$Step) {
            # Create output, append results and output specified fields to CSV, and return successful hosts
            $Fields = @('aid','batch_id','cloud_request_id','complete','deployment_step','errors',
                'offline_queued','session_id','stderr','stdout')
            if ($Include) { $Fields += $Include }
            $Output = @($Object).foreach{
                $i = [PSCustomObject]@{ aid = $_.aid; deployment_step = $Step }
                if ($Include) {
                    # Append 'Include' fields to output
                    @($Hosts).Where({ $_.device_id -eq $i.aid }).foreach{
                        @($_.PSObject.Properties).Where({ $Include -contains $_.Name }).foreach{
                            Set-Property $i $_.Name $_.Value
                        }
                    }
                }
                $i
            }
            Get-RtrResult $Object $Output | Select-Object $Fields | Export-Csv $Csv -Append -NoTypeInformation
            ($Object | Where-Object { ($_.complete -eq $true -and !$_.stderr) -or
                $_.offline_queued -eq $true }).aid
        }
        [System.Collections.Generic.List[object]]$Hosts = @()
        [System.Collections.Generic.List[string]]$List = @()
    }
    process {
        if ($GroupId) {
            if (($GroupId | Get-FalconHostGroupMember -Total) -gt 10000) {
                # Stop if number of members exceeds API limit
                throw "Group size exceeds maximum number of results. [10,000]"
            } else {
                # Retrieve Host Group member device_id and platform_name
                $Select = @('device_id','platform_name')
                if ($Include) { $Select += ($Include | Where-Object { $_ -ne 'platform_name' })}
                @($GroupId | Get-FalconHostGroupMember -Detailed -All | Select-Object $Select).foreach{
                    $Hosts.Add($_)
                }
            }
        } elseif ($HostId) {
            # Use provided Host identifiers
            @($HostId).foreach{ $List.Add($_) }
        }
    }
    end {
        if ($List) {
            # Use Host identifiers to also retrieve 'platform_name' and 'Include' fields
            $Select = @('device_id','platform_name')
            if ($Include) { $Select += ($Include | Where-Object { $_ -ne 'platform_name' })}
            @($List | Select-Object -Unique | Get-FalconHost | Select-Object $Select).foreach{
                $Hosts.Add($_)
            }
        }
        if ($Hosts) {
            # Check for existing 'CloudFile' and upload 'LocalFile' if chosen
            if (Test-Path $FilePath -PathType Leaf) { Update-CloudFile $PutFile $FilePath }
            try {
                # Force a base timeout of 30
                if (!$Timeout) { $Timeout = 30 }
                for ($i = 0; $i -lt ($Hosts | Measure-Object).Count; $i += 1000) {
                    # Start Real-time Response sessions in groups of 1,000 with 'HostTimeout' to force batch
                    $Param = @{
                        Id = @($Hosts[$i..($i + 999)].device_id)
                        Timeout = $Timeout
                        HostTimeout = ($Timeout - 5)
                    }
                    if ($QueueOffline) { $Param['QueueOffline'] = $QueueOffline }
                    $Session = Start-FalconSession @Param
                    [string[]]$SessionIds = if ($Session.batch_id) {
                        # Output result to CSV and return list of successful 'init' hosts
                        Write-RtrResult $Session.hosts init $Session.batch_id
                    }
                    if ($SessionIds) {
                        # Change to a 'temp' directory for each device by platform
                        Write-Host "[Invoke-FalconDeploy] Initiated session with $(($SessionIds |
                            Measure-Object).Count) host(s)..."

                        foreach ($Pair in (@{
                            Windows = ($Hosts | Where-Object { $SessionIds -contains $_.device_id -and
                                $_.platform_name -eq 'Windows' }).device_id
                            Mac = ($Hosts | Where-Object { $SessionIds -contains $_.device_id -and
                                $_.platform_name -eq 'Mac' }).device_id
                            Linux = ($Hosts | Where-Object { $SessionIds -contains $_.device_id -and
                                $_.platform_name -eq 'Linux' }).device_id
                        }).GetEnumerator().Where({ $_.Value })) {
                            # Define target temporary folder
                            [string]$TempDir = switch ($Pair.Key) {
                                'Windows' { "\Windows\Temp\$DeployName" }
                                'Mac' { "/tmp/$DeployName" }
                                'Linux' { "/tmp/$DeployName" }
                            }
                            # Script content for 'runscript' by platform and 'Archive' or 'File'
                            $Runscript = @{
                                Linux = @{
                                    Archive = if ($PutFile -match '\.(tar(.gz)?|tgz)$') {
                                        "if ! command -v tar &> /dev/null; then echo 'Missing application: tar';" +
                                            " exit 1; fi; tar -xf $PutFile; chmod +x $($TempDir,$RunFile -join
                                            '/'); exit 0"

                                    } else {
                                        "if ! command -v unzip &> /dev/null; then echo 'Missing application: unz" +
                                            "ip'; exit 1; fi; unzip $PutFile; chmod +x $($TempDir,$RunFile -join
                                            '/'); exit 0"

                                    }
                                    File = "chmod +x $($TempDir,$PutFile -join '/')"
                                }
                                Mac = @{
                                    Archive = if ($PutFile -match '\.(tar(.gz)?|tgz)$') {
                                        "if ! command -v tar &> /dev/null; then echo 'Missing application: tar';" +
                                            " exit 1; fi; tar -xf $PutFile; chmod +x $($TempDir,$RunFile -join
                                            '/'); exit 0"

                                    } else {
                                        "if ! command -v unzip &> /dev/null; then echo 'Missing application: unz" +
                                            "ip'; exit 1; fi; unzip $PutFile; chmod +x $($TempDir,$RunFile -join
                                            '/'); exit 0"

                                    }
                                }
                                Windows = @{
                                    Archive = "Expand-Archive $($TempDir,$PutFile -join '\') $TempDir"
                                }
                            }
                            foreach ($Cmd in @('mkdir','cd','put','runscript','run')) {
                                # Define Real-time Response command parameters
                                $Param = @{
                                    BatchId = $Session.batch_id
                                    Command = if ($Cmd -eq 'run') { 'runscript' } else { $Cmd }
                                    Argument = switch ($Cmd) {
                                        'mkdir' { $TempDir }
                                        'cd' { $TempDir }
                                        'put' { $PutFile }
                                        'runscript' {
                                            # Get 'Archive' or 'File' script by platform
                                            $Script = if ($Archive) {
                                                $Runscript.($Pair.Key).Archive
                                            } else {
                                                $Runscript.($Pair.Key).File
                                            }
                                            if ($Script) { '-Raw=```{0}```' -f $Script }
                                        }
                                        'run' {
                                            [string]$Join = if ($Pair.Key -eq 'Windows') { '\' } else { '/' }
                                            if ($Pair.Key -eq 'Windows') {
                                                # Use 'runscript' to start process and avoid timeout
                                                [string]$String = if ($Argument) {
                                                    ($TempDir,$RunFile -join $Join),$Argument -join ' '
                                                } else {
                                                    $TempDir,$RunFile -join $Join
                                                }
                                                [string]$Executable = if ($RunFile -match '\.cmd$') {
                                                    'cmd',"'/c $String'" -join ' '
                                                } else {
                                                    'powershell.exe',"'-c &{$String}'" -join ' '
                                                }
                                                '-Raw=```Start-Process',$Executable,
                                                "-RedirectStandardOutput '$($TempDir,'stdout.log' -join $Join)'",
                                                "-RedirectStandardError '$($TempDir,'stderr.log' -join $Join)'",
                                                ('-PassThru | ForEach-Object { "The process was successfully sta' +
                                                'rted"'),'}```' -join ' '
                                            } elseif ($Pair.Key -match '^(Linux|Mac)$') {
                                                # Use 'runscript' to start background process and avoid timeout
                                                [string]$String = if ($Argument) {
                                                    ($TempDir,$RunFile -join $Join),$Argument -join ' '
                                                } else {
                                                    $TempDir,$RunFile -join $Join
                                                }
                                                $String = "'$String > $($TempDir,'stdout.log' -join
                                                    $Join) 2> $($TempDir,'stderr.log' -join $Join) &'"

                                                if ($Pair.Key -eq 'Linux') {
                                                    ('-Raw=```(bash -c {0}); if [[ $? -eq 0 ]]; then echo "The p' +
                                                        'rocess was successfully started"; fi```') -f $String
                                                } else {
                                                    ('-Raw=```(zsh -c {0}); if [[ $? -eq 0 ]]; then echo "The pr' +
                                                    'ocess was successfully started"; fi```') -f $String
                                                }
                                            }
                                        }
                                    }
                                    OptionalHostId = if ($Cmd -eq 'mkdir') { $Pair.Value } else { $Optional }
                                    Timeout = $Timeout
                                }
                                if ($Param.OptionalHostId -and $Param.Argument) {
                                    # Issue command, output result to CSV and capture successful values
                                    Write-Host "[Invoke-FalconDeploy] Issuing '$Cmd' to $(($Param.OptionalHostId |
                                        Measure-Object).Count) $($Pair.Key) host(s)..."

                                    [string]$Step = if ($Cmd -eq 'runscript') { 'extract' } else { $Cmd }
                                    [string[]]$Optional = Write-RtrResult (
                                        Invoke-FalconAdminCommand @Param) $Step $Session.batch_id
                                }
                            }
                        }
                    }
                }
            } catch {
                throw $_
            } finally {
                if (Test-Path $Csv) { Get-ChildItem $Csv | Select-Object FullName,Length,LastWriteTime }
            }
        }
    }
}
function Invoke-FalconRtr {
<#
.SYNOPSIS
Start a Real-time Response session, execute a command and output the result
.DESCRIPTION
Requires 'Real Time Response: Read', 'Real Time Response: Write' or 'Real Time Response (Admin): Write'
depending on 'Command' provided, plus related permission(s) for 'Include' selection(s).
.PARAMETER Command
Real-time Response command
.PARAMETER Argument
Arguments to include with the command
.PARAMETER Timeout
Length of time to wait for a result, in seconds
.PARAMETER QueueOffline
Add non-responsive Hosts to the offline queue
.PARAMETER Include
Include additional properties
.PARAMETER GroupId
Host group identifier
.PARAMETER HostId
Host identifier
.LINK
https://github.com/crowdstrike/psfalcon/wiki/Invoke-FalconRtr
#>

    [CmdletBinding(DefaultParameterSetName='HostId',SupportsShouldProcess)]
    param(
        [Parameter(ParameterSetName='HostId',Mandatory,Position=1)]
        [Parameter(ParameterSetName='GroupId',Mandatory,Position=1)]
        [string]$Command,
        [Parameter(ParameterSetName='HostId',Position=2)]
        [Parameter(ParameterSetName='GroupId',Position=2)]
        [Alias('Arguments')]
        [string]$Argument,
        [Parameter(ParameterSetName='HostId',Position=3)]
        [Parameter(ParameterSetName='GroupId',Position=3)]
        [ValidateRange(30,600)]
        [int32]$Timeout,
        [Parameter(ParameterSetName='HostId',Position=4)]
        [Parameter(ParameterSetName='GroupId',Position=4)]
        [boolean]$QueueOffline,
        [Parameter(ParameterSetName='HostId',Position=5)]
        [Parameter(ParameterSetName='GroupId',Position=5)]
        [ValidateSet('agent_version','cid','external_ip','first_seen','hostname','last_seen','local_ip',
            'mac_address','os_build','os_version','platform_name','product_type','product_type_desc',
            'serial_number','system_manufacturer','system_product_name','tags',IgnoreCase=$false)]
        [string[]]$Include,
        [Parameter(ParameterSetName='GroupId',Mandatory)]
        [ValidatePattern('^[a-fA-F0-9]{32}$')]
        [string]$GroupId,
        [Parameter(ParameterSetName='HostId',Mandatory,ValueFromPipelineByPropertyName,ValueFromPipeline)]
        [ValidatePattern('^[a-fA-F0-9]{32}$')]
        [Alias('device_id','host_ids','aid','HostIds')]
        [string[]]$HostId
    )
    begin {
        if ($Timeout -and $Command -eq 'runscript' -and $Argument -notmatch '-Timeout=\d{2,3}') {
            # Force 'Timeout' into 'Arguments' when using 'runscript'
            $Argument += " -Timeout=$($Timeout)"
        }
        [System.Collections.Generic.List[string]]$List = @()
    }
    process {
        if ($GroupId) {
            if (($GroupId | Get-FalconHostGroupMember -Total) -gt 10000) {
                # Stop if number of members exceeds API limit
                throw "Group size exceeds maximum number of results. [10,000]"
            } else {
                # Retrieve Host Group member device_id and platform_name
                @($GroupId | Get-FalconHostGroupMember -All).foreach{ $List.Add($_) }
            }
        } elseif ($HostId) {
            # Use provided Host identifiers
            @($HostId).foreach{ $List.Add($_) }
        }
    }
    end {
        if ($List) {
            # Gather list of unique host identifiers and append 'GroupId' when present
            [object[]]$Hosts = @($List | Select-Object -Unique).foreach{ [PSCustomObject]@{ aid = $_ }}
            if ($GroupId) { @($Hosts).foreach{ Set-Property $_ 'group_id' $GroupId }}
            if ($Include) {
                foreach ($i in (Get-FalconHost -Id $Hosts.aid | Select-Object @($Include + 'device_id'))) {
                    foreach ($p in @($i.PSObject.Properties.Where({ $_.Name -ne 'device_id' }))) {
                        # Append 'Include' fields to output
                        @($Hosts | Where-Object { $_.aid -eq $i.device_id }).foreach{
                            Set-Property $_ $p.Name $p.Value
                        }
                    }
                }
            }
            # Force a base timeout of 30
            if (!$Timeout) { $Timeout = 30 }
            for ($i = 0; $i -lt ($Hosts | Measure-Object).Count; $i += 10000) {
                # Start batch Real-time Response session in groups of 10,000
                $Output = $Hosts[$i..($i + 9999)]
                $Init = @{ Id = $Output.aid; Timeout = $Timeout; HostTimeout = ($Timeout - 5) }
                if ($QueueOffline) { $Init['QueueOffline'] = $QueueOffline }
                $InitReq = Start-FalconSession @Init
                if ($InitReq.batch_id -or $InitReq.session_id) {
                    $Output = if ($InitReq.hosts) {
                        @(Get-RtrResult $InitReq.hosts $Output).foreach{
                            # Clear 'stdout' from batch initialization
                            if ($_.stdout) { $_.stdout = $null }
                            $_
                        }
                    } else {
                        Get-RtrResult $InitReq $Output
                    }
                    # Determine PSFalcon command, execute and capture result
                    [string]$Invoke = Get-RtrCommand $Command
                    $Cmd = @{ Command = $Command; Timeout = $Timeout; HostTimeout = ($Timeout - 5) }
                    if ($Argument) { $Cmd['Argument'] = $Argument }
                    if ($QueueOffline -ne $true) { $Cmd['Wait'] = $true }
                    $CmdReq = $InitReq | & $Invoke @Cmd
                    $Output = Get-RtrResult $CmdReq $Output
                    [string[]]$Select = @($Output).foreach{
                        # Clear 'stdout' for batch 'get' requests
                        if ($_.stdout -and $_.batch_get_cmd_req_id) { $_.stdout = $null }
                        if ($_.stdout -and $Cmd.Command -eq 'runscript') {
                            # Attempt to convert 'stdout' from Json for 'runscript'
                            $StdOut = try { $_.stdout | ConvertFrom-Json } catch { $null }
                            if ($StdOut) { $_.stdout = $StdOut }
                        }
                        # Output list of fields for each object
                        $_.PSObject.Properties.Name
                    } | Sort-Object -Unique
                    # Force output of all unique fields
                    $Output | Select-Object $Select
                }
            }
        }
    }
}
Register-ArgumentCompleter -CommandName Invoke-FalconRtr -ParameterName Command -ScriptBlock {Get-RtrCommand}