SentinelOne.Tools.psm1

function Get-S1Command {
  [CmdletBinding()]
  param (
    [Parameter(Mandatory = $true)]
    [string]
    $Command,

    [Parameter(Mandatory = $false)]
    [string[]]
    $Parameters
  )

  process {
    if ($Parameters) {
      $params = $Parameters -join ''
      return "Import-Module SentinelOne.Tools
$($params.Substring(1)) | $command"

    } else {
      return "Import-Module SentinelOne.Tools
$command"

    }
  }
}

function Get-S1CommandParameter {
  [CmdletBinding()]
  param (
      [Parameter(Mandatory = $true)]
      [pscustomobject]
      $Invocation,

      [Parameter(Mandatory = $true,
      ValueFromPipelineByPropertyName = $true)]
      [String]
      $ApiEndpoint
  )

  Begin {
    $mi = @{BoundParameters = @{}}
  }

  Process {
    $mi.BoundParameters += [System.Collections.Hashtable]::new($Invocation.BoundParameters)
    $ApiEndpoint = $ApiEndpoint -replace '(\?(&+))','?' -replace '&+','&' -replace '&$',''
    Write-Debug $ApiEndpoint
    ($ApiEndpoint.Split("?")[1]).Split("&") | & { process {
      if (!($_.StartsWith('limit='))) {
        $mi.BoundParameters += @{ $_.Split('=')[0] = $_.Split('=')[1] }
      }
    }}

    if ($mi.BoundParameters.Keys -notmatch 'InputObject|SaveCommand') {
      $txt += ",[pscustomobject]@{"
      $txt += $mi.BoundParameters.Keys | & { process {
        if ($_ -match 'Filter') {
          " $_ = '$(($mi.BoundParameters.$_ | ConvertTo-Json) -replace '"",','"";' -replace ':',"" ="" -replace '{','@{' -replace '\s','')';"
        } elseif ($_ -notmatch 'InputObject|SaveCommand') {
          "$_ = '$($mi.BoundParameters.$_)';"
        }
      }}
      $txt += "}"
    }

    $txt -join ''
  }
}

function Get-S1Filter {
  [CmdletBinding(DefaultParameterSetName = 'All')]
  param (
    [Parameter(Mandatory = $true,
      ValueFromPipelineByPropertyName = $true)]
    [string]
    $ApiEndpoint,

    [Parameter(Mandatory = $true,
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true)]
    [pscustomobject]
    $InputObject
  )

  Begin {
    # Remove duplicate code
    $filterBy = {
      if (([string]::IsNullOrEmpty($value) -or ([string]::IsNullOrWhiteSpace($value)))) {
        return $ApiEndpoint
      }
      $pattern = "^(.*$key=)([^&]+)(.*)$"
      if ($ApiEndpoint -match $pattern) {
        return ($ApiEndpoint -replace $pattern,('$1 {0} $3' -f $value) -replace ' ', '')
      } else {
        return ("{0}{1}$key={2}" -f $ApiEndpoint, $(if ($ApiEndpoint.IndexOf('?') -ne -1) {"&"} else {"?"}), $value)
      }
    }
  }

  Process {

    switch -regex (($InputObject | Get-Member -MemberType Properties).Name) {
      accountId {
        $key,$value = 'accountIds',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      siteId {
        $key,$value = 'siteIds',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      groupId {
        $key,$value = 'groupIds',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      agentId {
        $key,$value = 'agentIds',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      agentId {
        $key,$value = 'agentIds',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      # # Unsure what this is used for.. Commenting out to prevent anything breaking
      # collectionId {
      # $key,$value = 'collectionIds',$InputObject.$_
      # $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
      # continue
      # }

      # Specific - Multiple
      GetByRegistrationToken {
        $key,$value = 'registrationToken',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      GetByName {
        $key,$value = 'name',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      GetByThreatId {
        $key,$value = 'threatIds',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      GetByUserId {
        $key,$value = 'userIds',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      # Specific to Get-S1Agent
      GetAgentByComputerName {
        $key,$value = 'computerName',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      # Specific to Get-S1Activity
      GetActivityById {
        $key,$value = 'activityIds',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      GetActivityByActivityType {
        $key,$value = 'activityTypes',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      # Specific to Get-S1Site
      GetSiteByType {
        $key,$value = 'siteType',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      GetSiteByState {
        $key,$value = 'state',$InputObject.$_.ToLower()
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }

      # Specific to Get-S1User
      GetByUserEmail {
        $key,$value = 'email',$InputObject.$_
        $ApiEndpoint = Invoke-Command -ScriptBlock $filterBy
        continue
      }
    }
  }

  End {
    Write-Output ($ApiEndpoint -replace '\?\&','?')
    Remove-Variable -Name InputObject
  }
}

# Private Function Example - Replace With Your Function
function Add-PrivateFunction {

  [CmdletBinding()]

  Param (
    # Your parameters go here...
  )

  # Your function code goes here...
  Write-Output "Your private function ran!"

}

function Get-SentinelOneConfig {

  param(
    [Parameter(Mandatory=$false,
      HelpMessage = 'Local app data folder name')]
    [string] $ConfigFolderName,

    [Parameter(Mandatory=$false,
      HelpMessage = 'XML config file name')]
    [string] $ConfigFileName
  )

  Begin {
    Write-Verbose "Retrieving config file"

    if (!$PSBoundParameters.ContainsKey('ConfigFolderName')) {
      $ConfigFolderName = "SentinelOne.Tools"
    }

    if (!$PSBoundParameters.ContainsKey('ConfigFileName')) {
      $ConfigFileName = "SentinelOne.Tools.xml"
    }

    # Define variables
    $configFileFolder = [io.path]::combine($ENV:LOCALAPPDATA, $ConfigFolderName)
    $configFile = [io.path]::combine($configFileFolder, $ConfigFileName)

    Write-Verbose ("Config file location: {0}" -f $configFile)
    # Check if folder / config file exists
    if (!(Test-Path -Path $configFile)) {
      # File does not exist, create it
      Write-Verbose ("Config file does not exist. Running New-SentinelOneConfig")
      Write-Warning ("Creating new config file: {0}" -f $configFile)
      try {
        New-SentinelOneConfig -ConfigFolderName $ConfigFolderName -ConfigFileName $ConfigFileName
      } catch {
        Write-Error "New-SentinelOneConfig failed to complete successfully. Try running it manually."
        exit 1
      }
    }
  }

  Process {

    $config = Import-Clixml -Path $configFile

    Write-Verbose ("Config imported")
    Write-Debug ("Config compiled: {0}" -f $config)
  }

  End {
    Write-Output $config
    $config = $null
    Remove-Variable -Name config
  }
}

function Get-S1Activity {
  <#
  .DESCRIPTION
    Get the activities, and their data, that match the filters. We recommend that you set some values for the filters. The full list will be too large to be useful.
  .SYNOPSIS
    Get the activities, and their data, that match the filters.
  .PARAMETER ManagementUrl
    The base URL for the customer's SentinelOne environment
  .PARAMETER Credential
    PSCredential Object containing the API Key in the Password field
 
  .PARAMETER Filter
    Filters to apply to the query in hashtable format.
    eg.
    $filters = @{
      groupIds = @(225494730938493804, 225494730938493915)
      sortBy = 'id'
    }
    Get-S1Activity -Filter $filters
  .PARAMETER ByActivityID
    ID of the activity
  .PARAMETER ByActivityType
    List of Activity Type IDs to filter results by
  .PARAMETER ByGroupID
    SentinelOne Group ID
  .PARAMETER BySiteID
    SentinelOne Site ID
  .OUTPUTS
    Returns a PSCustomObject containing the Activities and their data
    Returns ErrorObject if an error is encountered
  .EXAMPLE
    Get-S1Activity -ManagementUrl "apne1-1110-mssp.sentinelone.net" -Credential (Get-PSCredential)
 
  #>

  [CmdletBinding(DefaultParameterSetName = 'All')]
  param(
        [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [string] $ManagementUrl,

        [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [PsCredential] $Credential,


        [Parameter(Mandatory=$false,
      HelpMessage = 'Filters to apply to the query in hashtable format.
      eg.
      $filters = @{
        groupIds = @(225494730938493804, 225494730938493915)
        sortBy = "id"
      }
      Get-S1Activity -Filter $filters'
)]
    [hashtable] $Filter,

    [Alias('activityId')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'ID of the activity',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByActivityID,

    [Alias('activityTypeId')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'List of Activity Type IDs to filter results by',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByActivityType,

    [Parameter(Mandatory = $false)]
    [switch]
    $SaveCommand,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Pipeline handler',
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true,
      DontShow = $true)]
    [pscustomobject]$InputObject
    )

  Begin {
    # Use Max Page Size to reduce the number of requests
    $limit = 1000
    $ApiVersion = '2.1'
    $ApiEndpoint = [System.Text.StringBuilder]'activities'

    $Parameters = @()
    $Parameters += ("limit={0}" -f $limit)

    if ($Filter) {
      # An Agent filter was specified
      $Parameters += $Filter.keys | & { process {
        "$_={0}" -f $(if ($Filter.$_.GetType() -eq [Object[]]) {Join-String -Separator "," -InputObject $Filter.$_} else {$Filter.$_})
      }}
    }

    $ParamString = $Parameters -join '&'

    $null = $ApiEndpoint.Append("?")
    $null = $ApiEndpoint.Append($ParamString)
    $ApiEndpoint = $ApiEndpoint.ToString()

    if ($ManagementUrl) {
      $functionParameters += @{
        ManagementUrl = $ManagementUrl
      }
    }

    if ($Credential) {
      $functionParameters += @{
        Credential = $Credential
      }
    }
  }

  Process {

    if ($ByActivityID) { # Specific
      $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)ids=', '$1activityIds='
      $ApiEndpoint = [pscustomobject]@{GetActivityById = $ByActivityID} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif ($ByActivityType) { # Specific
      $ApiEndpoint = [pscustomobject]@{GetActivityByActivityType = $ByActivityType} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif ($InputObject) { # Generic
      if ($InputObject.PSObject.Properties.Name -contains 'threatId') {
        $ApiEndpoint = [pscustomobject]@{GetByThreatId = $InputObject.threatId} | Get-S1Filter -ApiEndpoint $ApiEndpoint
      } elseif ($InputObject.PSObject.Properties.Name -contains 'userId') {
        $ApiEndpoint = [pscustomobject]@{GetByUserId = $InputObject.userId} | Get-S1Filter -ApiEndpoint $ApiEndpoint
      } elseif ($InputObject.GetType() -eq [int]) { # Strings parsed through pipeline, assume they are names
        $ApiEndpoint = [pscustomobject]@{GetActivityById = $InputObject} | Get-S1Filter -ApiEndpoint $ApiEndpoint
      } else {
        $ApiEndpoint = Get-S1Filter -ApiEndpoint $ApiEndpoint -InputObject $InputObject
      }
    }

    $ApiEndpoint = $ApiEndpoint -replace 'activityIds', 'ids'

    # Build
    $functionParameters += @{
      ApiEndpoint = $ApiEndpoint
      ApiVersion  = $ApiVersion
    }

    # Save the command
    if ($SaveCommand) {
      $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)ids=', '$1activityIds='
      $params += [pscustomobject]@{ApiEndpoint = $ApiEndpoint} | Get-S1CommandParameter -Invocation $MyInvocation
      return
    }

    # Error checking is all done in the following function so no extra error checking needs to be done.
    # Calling the function and returning the results instantly helps for better pipeline processing
    Invoke-SentinelOneApiRequest @functionParameters | & { process {
      $result = $_ | Select-Object -Property *,'activityId','activityTypeId'
      $result.activityId = $_.id
      $result.activityTypeId = $_.activityType
      return $result
    }}
  }

  End {
    if ($SaveCommand) {
      Get-S1Command -Command $MyInvocation.MyCommand.Name -Parameters $params
    }
  }
}

function Get-S1ActivityType {
  <#
  .DESCRIPTION
    Get a list of activity types. This is useful to see valid values to filter activities in other commands.
  .SYNOPSIS
    Get a list of activity types.
  .PARAMETER ManagementUrl
    The base URL for the customer's SentinelOne environment
  .PARAMETER Credential
    PSCredential Object containing the API Key in the Password field
 
  .OUTPUTS
    Returns a PSCustomObject containing action Ids, names and description templates
    Returns ErrorObject if an error is encountered
  .EXAMPLE
    Get-S1ActivityType -ManagementUrl "apne1-1110-mssp.sentinelone.net" -Credential (Get-PSCredential)
 
  #>

  [CmdletBinding(DefaultParameterSetName = 'All')]
  param(
        [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [string] $ManagementUrl,

        [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [PsCredential] $Credential,


        [Alias('activityTypeId')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'ID of the activity',
      ValueFromPipelineByPropertyName = $true)]
    [int]$ByActivityTypeID,

    [Alias('activityAction')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'Name of the action for the activity',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByActionName,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Use regex for action name',
      ValueFromPipelineByPropertyName = $true)]
    [switch]$Regex,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Pipeline handler',
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true,
      DontShow = $true)]
    [pscustomobject]$InputObject
    )

  Begin {
    $ApiVersion = '2.1'
    $ApiEndpoint = 'activities/types'

    if ($ManagementUrl) {
      $functionParameters += @{
        ManagementUrl = $ManagementUrl
      }
    }

    if ($Credential) {
      $functionParameters += @{
        Credential = $Credential
      }
    }
  }

  Process {

    $functionParameters += @{
      ApiEndpoint = $ApiEndpoint
      ApiVersion  = $ApiVersion
    }

    # API Endpoint does not use pagination
    Invoke-SentinelOneApiRequest @functionParameters | & { process {
      $result = $_ | Select-Object -Property id,action,descriptionTemplate,activityTypeId
      $result.activityTypeId = $_.id
      if ($ByActivityTypeID) {
        return $result | Where-Object { $_.id -eq $ByActivityTypeID }
      } elseif ($ByActionName) {
        if ($Regex) {
          return $result | Where-Object { $_.action -match $ByActionName }
        } else {
          return $result | Where-Object { $_.action -eq $ByActionName }
        }
      } else {
        return $result
      }
    }}
  }
}

# Function that returns a PSCustomObject containing SentinelOne agent data

function Get-S1Agent {
  <#
  .DESCRIPTION
    Get the Agents, and their data, that match the filter. This command gives the Agent ID, which you can use in other commands.
  .SYNOPSIS
    Get the Agents, and their data, that match the filter.
  .PARAMETER ManagementUrl
    The base URL for the customer's SentinelOne environment
  .PARAMETER Credential
    PSCredential Object containing the API Key in the Password field
 
  .PARAMETER Filter
    Filters to apply to the query in hashtable format.
    eg.
    $filters = @{
      groupIds = @(225494730938493804, 225494730938493915)
      sortBy = 'id'
      computerName__like = 'azm'
    }
    Get-S1Agent -Filter $filters
  .PARAMETER ByAgentID
    ID of the SentinelOne agent
  .PARAMETER ByName
    Computer Name with the SentinelOne agent installed
  .PARAMETER ByGroupID
    Group ID containing SentinelOne agents
  .PARAMETER BySiteID
    Site ID containing SentinelOne agent
  .OUTPUTS
    Returns a PSCustomObject containing the Agents and their data
    Returns ErrorObject if an error is encountered
  .EXAMPLE
    Get-S1Agent -ManagementUrl "apne1-1110-mssp.sentinelone.net" -Credential (Get-PSCredential)
 
  #>

  [CmdletBinding(DefaultParameterSetName = 'All')]
  param(
        [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [string] $ManagementUrl,

        [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [PsCredential] $Credential,


        [Parameter(Mandatory=$false,
      HelpMessage = 'Filters to apply to the query in hashtable format.
      eg.
      $filters = @{
        groupIds = [225494730938493804, 225494730938493915]
        sortBy = "id"
        computerName__like = "azm"
      }
      Get-S1Agents -Filter $filters'
)]
    [hashtable] $Filter,

    [Alias('agentId')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'ID of the agent',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByAgentID,

    [Alias('computerName')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'Name of the computer the agent is installed on',
      #ParameterSetName = 'ByName',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByName,

    [Parameter(Mandatory = $false)]
    [switch]
    $SaveCommand,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Pipeline handler',
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true,
      DontShow = $true)]
    [pscustomobject]$InputObject
    )

  Begin {
    # Use Max Page Size to reduce the number of requests
    $limit = 1000
    $ApiVersion = '2.1'
    $ApiEndpoint = [System.Text.StringBuilder]'agents'

    $Parameters = @()
    $Parameters += ("limit={0}" -f $limit)

    if ($Filter) {
      # An Agent filter was specified
      $Parameters += $Filter.keys | & { process {
        "$_={0}" -f $(if ($Filter.$_.GetType() -eq [Object[]]) {Join-String -Separator "," -InputObject $Filter.$_} else {$Filter.$_})
      }}
    }

    $ParamString = $Parameters -join '&'

    $null = $ApiEndpoint.Append("?")
    $null = $ApiEndpoint.Append($ParamString)
    $ApiEndpoint = $ApiEndpoint.ToString()

    if ($ManagementUrl) {
      $functionParameters += @{
        ManagementUrl = $ManagementUrl
      }
    }

    if ($Credential) {
      $functionParameters += @{
        Credential = $Credential
      }
    }
  }

  Process {

    if ($ByAgentID) { # Specific
      $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)ids=', '$1agentIds='
      $ApiEndpoint = [pscustomobject]@{agentId = $ByAgentID} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif ($ByName) { # Specific
      $ApiEndpoint = [pscustomobject]@{GetAgentByComputerName = $ByName} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif ($InputObject) { # Generic
      if ($InputObject.GetType() -eq [string]) { # Strings parsed through pipeline, assume they are names
        $ApiEndpoint = [pscustomobject]@{GetAgentByComputerName = $InputObject} | Get-S1Filter -ApiEndpoint $ApiEndpoint
      } else {
        $ApiEndpoint = Get-S1Filter -ApiEndpoint $ApiEndpoint -InputObject $InputObject
      }
    }

    $ApiEndpoint = $ApiEndpoint -replace 'agentIds', 'ids'

    # Build
    $functionParameters += @{
      ApiEndpoint = $ApiEndpoint
      ApiVersion  = $ApiVersion
    }

    # Save the command
    if ($SaveCommand) {
      $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)ids=', '$1agentIds='
      $ApiEndpoint = $ApiEndpoint -replace "(\?|\&)(agentIds=[^&]+)",'$1'
      $ApiEndpoint = $ApiEndpoint -replace "(\?|\&)(computerName=[^&]+)",'$1'
      $params += [pscustomobject]@{ApiEndpoint = $ApiEndpoint} | Get-S1CommandParameter -Invocation $MyInvocation
      return
    }

    # Error checking is all done in the following function so no extra error checking needs to be done.
    # Calling the function and returning the results instantly helps for better pipeline processing
    Invoke-SentinelOneApiRequest @functionParameters | & { process {
      $result = $_ | Select-Object -Property *,'agentId'
      $result.agentId = $_.id
      return $result
    }}
  }

  End {
    if ($SaveCommand) {
      Get-S1Command -Command $MyInvocation.MyCommand.Name -Parameters $params
    }
  }
}

# Function to get the count of Agents that match a filter.

function Get-S1AgentCount {
  <#
  .DESCRIPTION
    Get the count of Agents that match a filter. This command is useful to run before you run other commands. You will be able to manage Agent maintenance better if you know how many Agents will get a command that takes time (such as Update Software).
  .SYNOPSIS
    Get the count of Agents that match a filter.
  .PARAMETER ManagementUrl
    The base URL for the customer's SentinelOne environment
  .PARAMETER Credential
    PSCredential Object containing the API Key in the Password field
 
  .PARAMETER Filter
    Filters to apply to the query in hashtable format.
    eg.
    $filters = @{
      groupIds = @(225494730938493804, 225494730938493915)
      sortBy = 'id'
      computerName__like = 'azm'
    }
    Get-S1AgentCount -Filter $filters
  .OUTPUTS
    Returns a PSCustomObject containing the number of agents matching the criteria
    Returns ErrorObject if an error is encountered
  .EXAMPLE
    Get-S1AgentCount -ManagementUrl "apne1-1110-mssp.sentinelone.net" -Credential (Get-PSCredential)
 
  #>

  [CmdletBinding(DefaultParameterSetName = 'All')]
  param(
        [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [string] $ManagementUrl,

        [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [PsCredential] $Credential,


        [Parameter(Mandatory=$false,
      HelpMessage = 'Filters to apply to the query in hashtable format.
      eg.
      $filters = @{
        groupIds = @(225494730938493804, 225494730938493915)
        sortBy = "id"
        computerName__like = "azm"
      }
      Get-S1AgentCount -Filter $filters'
)]
    [hashtable] $Filter,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Pipeline handler',
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true,
      DontShow = $true)]
    [pscustomobject]$InputObject
    )

  Begin {
    # Use Max Page Size to reduce the number of requests
    #$limit = 1000
    $ApiVersion = '2.1'
    $ApiEndpoint = [System.Text.StringBuilder]'agents/count'

    $Parameters = @()
    #$Parameters += ("limit={0}" -f $limit)

    if ($Filter) {
      # An Agent filter was specified
      $Parameters += $Filter.keys | & { process {
        "$_={0}" -f $(if ($Filter.$_.GetType() -eq [Object[]]) {Join-String -Separator "," -InputObject $Filter.$_} else {$Filter.$_})
      }}
    }

    $ParamString = $Parameters -join '&'

    $null = $ApiEndpoint.Append("?")
    $null = $ApiEndpoint.Append($ParamString)
    $ApiEndpoint = $ApiEndpoint.ToString()

    if ($ManagementUrl) {
      $functionParameters += @{
        ManagementUrl = $ManagementUrl
      }
    }

    if ($Credential) {
      $functionParameters += @{
        Credential = $Credential
      }
    }

    $AgentCount = 0
  }

  Process {
    if ($InputObject) { # Generic
      if (($InputObject | Get-Member -MemberType Properties).Name -contains 'agentId') { # No need for an api call
        $AgentCount += $InputObject.agentId.Count
        return
      }
      $ApiEndpoint = Get-S1Filter -ApiEndpoint $ApiEndpoint -InputObject $InputObject
      $ApiEndpoint = $ApiEndpoint -replace 'computerName=','computerName__like='
      $ApiEndpoint = $ApiEndpoint -replace '(\?|&)agentIds=[^&]+',''
    }

    $functionParameters += @{
      ApiEndpoint = $ApiEndpoint
      ApiVersion  = $ApiVersion
    }

    # API Call to get the tag
    $AgentCountResponse = Invoke-SentinelOneApiRequest @functionParameters

    if ($AgentCountResponse -is [HashTable] -and $AgentCountResponse.Error) {
      Write-Error -Message "Error calling Agents API. Response Code: $($AgentCountResponse.Code) Note: $($AgentCountResponse.Note)" -ErrorId $AgentCountResponse.Code -CategoryReason $AgentCountResponse.Note
      return $AgentCountResponse
    }

    $AgentCount += $AgentCountResponse.total
  }

  End {
    [pscustomobject]@{total = $AgentCount}
  }
}

function Invoke-SentinelOneApiRequest {
  <#
  .SYNOPSIS
    Invoke the SentinelOne API
  .DESCRIPTION
    This function is intended to be called by other functions for specific resources/interactions
  .PARAMETER Uri
    Base API URL for the API Call
  .PARAMETER Credential
    PSCredential Object with the API Key stored in the Password property of the object.
  .PARAMETER Method
    Valid HTTP Method to use: GET (Default), POST, DELETE, PUT
  .PARAMETER Body
    PSCustomObject containing data to be sent as HTTP Request Body in JSON format.
  .PARAMETER Depth
    How deep are we going?
  .PARAMETER Cursor
    See next page of unknown number of items.
  .OUTPUTS
    PSCustomObject containing results if successful. May be $null if no data is returned
    ErrorObject containing details of error if one is encountered.
  #>

  [CmdletBinding()]

  param(
    [Parameter(Mandatory=$true,
      HelpMessage = 'API endpoint eg. agents/count')]
    [string]  $ApiEndpoint,

    [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
      [ValidateScript({
        $TypeName = $_ | Get-Member | Select-Object -ExpandProperty TypeName -Unique
        if ($TypeName -eq 'System.String' -or $TypeName -eq 'System.UriBuilder') {
          [System.UriBuilder]$_
        }
      })]
    [System.UriBuilder] $ManagementUrl,

    [Parameter(Mandatory=$false,
      HelpMessage = 'The Api Version to use')]
    [float] $ApiVersion,

    [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password property')]
    [PSCredential]  $Credential,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Method to use when making the request. Defaults to GET')]
    [ValidateSet("Post","Get","Put","Delete")]
    [string] $Method = "GET",

    [Parameter(Mandatory=$false,
      HelpMessage = 'PsCustomObject containing data that will be sent as the Json Body')]
    [PsCustomObject] $Body,

    [Parameter(Mandatory=$false,
      HelpMessage = 'How deep are we?')]
    [int] $Depth = 0,

    [Parameter(Mandatory=$false,
      HelpMessage = 'See next page of unknown number of items.')]
    [string] $Cursor
    )

  Begin {
    $Me = $MyInvocation.MyCommand.Name

    Write-Verbose $Me

    # Setup Error Object structure
    $ErrorObject = @{
      Code                  =   $null
      Error                 =   $false
      Type                  =   $null
      Note                  =   $null
      Raw                   =   $_
    }

    # Import config
    if (($PSBoundParameters.ContainsKey('ManagementUrl')) -and ($PSBoundParameters.ContainsKey('ApiVersion')) -and ($PSBoundParameters.ContainsKey('Credential'))) {
      $config = [pscustomobject]@{
        managementUrl = $null
        apiVersion = $null
        credential = $null
      }
    } else {
      $config = Get-SentinelOneConfig
    }

    # Use parameters if set
    if ($PSBoundParameters.ContainsKey('ManagementUrl')) {
      $config.managementUrl = $ManagementUrl.Host
    }

    if ($PSBoundParameters.ContainsKey('ApiVersion')) {
      $config.apiVersion = $ApiVersion
    }

    if ($PSBoundParameters.ContainsKey('Credential')) {
      $config.credential = $Credential
    }

    Write-Debug ("Config compiled: {0}" -f $config)

    # Build URI
    $Uri = [System.UriBuilder]("https://{0}/web/api/v{1}/{2}" -f $config.managementUrl, $config.apiVersion, $ApiEndpoint)

    if (($Method -eq 'GET') -and $Body) {
      throw "Cannot specify Request Body for Method GET."
    }

    $Header = @{}
    $ApiKey = $config.credential.GetNetworkCredential().Password
    $Header.Add('Authorization', ("ApiToken {0}" -f $ApiKey))
    $Header.Add('Content-Type', 'application/json')

    # Buld Rest Method parameters
    $params = @{
      Method                  = $Method
      Uri                     = $Uri.Uri
      Headers                 = $Header
      ResponseHeadersVariable = 'ResponseHeaders'
      ContentType             = 'application/json'
    }

    # Add body if present
    if ($Body) {
      Write-Verbose "$Me : Body supplied"
      $params += @{
        Body = $($Body | ConvertTo-Json -Depth 15)
      }
    }

    # Add cursor if present
    if ($Cursor) {
      Write-Verbose "$Me : Cursor supplied"

      $pattern = "^(.*cursor=)([^&]+)(.*)$"
      if ($Uri.Uri -match $pattern) {
        $params.Uri = ($Uri.Uri -replace $pattern,('$1{0}$3' -f $Cursor))
      } else {
        $params.Uri = ('{0}{1}cursor={2}' -f $Uri.Uri, $(if ($Uri.Query.IndexOf('?') -eq 0) {"&"} else {"?"}), $Cursor)
      }
    }

    Write-Debug "Parameters: $($params | ConvertTo-Json -Depth 15)"
  }

  Process {
    $Results = $null

    # Enforce TLSv1.2
    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12

    # Code to make the API Call. Used: $Results,$ResponseHeaders = Invoke-Command -ScriptBlock $apiCallCode -- this is to remove code duplication
    $apiCallCode = {
      try {
        $Results = Invoke-RestMethod @params
      } catch {
        $Exception = $_.Exception
        Write-Verbose "$Me : Exception : $($Exception.Response.StatusCode)"
        $ErrorObject.Error = $true
        $ErrorObject.Code = $Exception.Response.StatusCode
        $ErrorObject.Note = $Exception.Message
        $ErrorObject.Raw = $Exception
        Write-Debug ($ErrorObject | ConvertTo-Json -Depth 3)

        return $ErrorObject,$null
      }
      Write-Debug ($ResponseHeaders | ConvertTo-Json -Depth 3)
      Write-Debug ($Results | ConvertTo-Json -Depth 15)

      return $Results,$ResponseHeaders
    }

    $processResults = {
      if ($Results -is [HashTable] -and $Results.ContainsKey('Error') -and $Results.Error) {
        Write-Debug ($Results | ConvertTo-Json)
        Write-Output $Results
        Write-Error "$Me : Encountered error getting additional results. $($ErrorObject.Code) : $($ErrorObject.Note)"
      } else {
        Write-Output $Results.data
      }
    }

    $Results,$ResponseHeaders = Invoke-Command -ScriptBlock $apiCallCode
    Invoke-Command -ScriptBlock $processResults

    # Check if we have a cursor to more results
    if (($Results | Get-Member | Select-Object -ExcludeProperty Name -Unique) -match "pagination" -and $null -ne $Results.pagination.nextCursor) {
      do {
        $Cursor = $Results.pagination.nextCursor
        $params.Uri = ('{0}{1}cursor={2}' -f $Uri.Uri, $(if ($Uri.Query.IndexOf('?') -eq 0) {"&"} else {"?"}), $Cursor)

        $Results,$ResponseHeaders = Invoke-Command -ScriptBlock $apiCallCode
        Invoke-Command -ScriptBlock $processResults
      } while ($null -ne $Results.pagination.nextCursor)
    }
  }

  End {
    Write-Verbose "Clearing variables"
    $Credential = $null
    Remove-Variable -Name config
    Remove-Variable -Name Credential
    Remove-Variable -Name Results
    Remove-Variable -Name ErrorObject
    $pattern = $null
    Remove-Variable -Name pattern
    Remove-Variable -Name ResponseHeaders
    Remove-Variable -Name apiCallCode
    Remove-Variable -Name processResults
    return
  }
}

# Function that returns a PSCustomObject containing SentinelOne group data

function Get-S1Group {
  <#
  .DESCRIPTION
    Get the SentinelOne groups that match the filter.
  .SYNOPSIS
    Get the SentinelOne groups that match the filter.
  .PARAMETER ManagementUrl
    The base URL for the customer's SentinelOne environment
  .PARAMETER Credential
    PSCredential Object containing the API Key in the Password field
 
  .PARAMETER Filter
    Filters to apply to the query in hashtable format.
    eg.
    $filters = @{
      groupIds = @(225494730938493804, 225494730938493915)
      sortBy = 'id'
      computerName__like = 'azm'
    }
    Get-S1Agent -Filter $filters
  .PARAMETER ByGroupID
    Group ID
  .PARAMETER ByName
    Exact match of the group name
  .PARAMETER ByRegistrationToken
    Find groups matching a registration token
  .OUTPUTS
    Returns a PSCustomObject containing the groups and its data
    Returns ErrorObject if an error is encountered
  .EXAMPLE
    Get-S1Group
 
  #>

  [CmdletBinding(DefaultParameterSetName = 'All')]
  param(
        [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [string] $ManagementUrl,

        [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [PsCredential] $Credential,


        [Parameter(Mandatory=$false,
      HelpMessage = 'Filters to apply to the query in hashtable format.
      eg.
      $filters = @{
        groupIds = [225494730938493804, 225494730938493915]
        sortBy = "id"
        computerName__like = "azm"
      }
      Get-S1Agents -Filter $filters'
)]
    [hashtable] $Filter,

    [Alias('groupId')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'ID of the group',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByGroupID,

    [Alias('groupName')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'Name of the SentinelOne group',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByName,

    [Alias('registrationToken')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'Find groups matching a registration token',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByRegistrationToken,

    [Parameter(Mandatory = $false)]
    [switch]
    $SaveCommand,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Pipeline handler',
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true,
      DontShow = $true)]
    [pscustomobject]$InputObject
    )

  Begin {
    # Use Max Page Size to reduce the number of requests
    $limit = 200
    $ApiVersion = '2.1'
    $ApiEndpoint = [System.Text.StringBuilder]'groups'

    $Parameters = @()
    $Parameters += ("limit={0}" -f $limit)

    if ($Filter) {
      # An Agent filter was specified
      $Parameters += $Filter.keys | & { process {
        "$_={0}" -f $(if ($Filter.$_.GetType() -eq [Object[]]) {Join-String -Separator "," -InputObject $Filter.$_} else {$Filter.$_})
      }}
    }

    $ParamString = $Parameters -join '&'

    $null = $ApiEndpoint.Append("?")
    $null = $ApiEndpoint.Append($ParamString)
    $ApiEndpoint = $ApiEndpoint.ToString()

    if ($ManagementUrl) {
      $functionParameters += @{
        ManagementUrl = $ManagementUrl
      }
    }

    if ($Credential) {
      $functionParameters += @{
        Credential = $Credential
      }
    }
  }

  Process {

    if ($ByName) { # Specific
      $ApiEndpoint = [pscustomobject]@{GetByName = $ByName} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif($ByRegistrationToken -and $InputObject) { # Specific
      if ('state' -notin (($InputObject | Get-Member -MemberType Properties).Name)) {
        $ApiEndpoint = [pscustomobject]@{GetByRegistrationToken = $ByRegistrationToken} | Get-S1Filter -ApiEndpoint $ApiEndpoint
      }
    } elseif($ByRegistrationToken) { # Specific
      $ApiEndpoint = [pscustomobject]@{GetByRegistrationToken = $ByRegistrationToken} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif ($InputObject) { # Generic
      $ApiEndpoint = Get-S1Filter -ApiEndpoint $ApiEndpoint -InputObject $InputObject
    }

    $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)(agentIds=[^&]+)','$1'

    # Save the command
    if ($SaveCommand) {
      $ApiEndpoint = $ApiEndpoint -replace "(\?|\&)(registrationToken=[^&]+)",'$1'
      $ApiEndpoint = $ApiEndpoint -replace "(\?|\&)(groupIds=[^&]+)",'$1'
      $params += [pscustomobject]@{ApiEndpoint = $ApiEndpoint} | Get-S1CommandParameter -Invocation $MyInvocation
      return
    }

    # ByGroupID? overwrite endpoint
    if ($ByGroupID) { # Specific
      $ApiEndpoint = "groups/$ByGroupID"
    }

    $functionParameters += @{
      ApiEndpoint = $ApiEndpoint
      ApiVersion  = $ApiVersion
    }

    # Error checking is all done in the following function so no extra error checking needs to be done.
    # Calling the function and returning the results instantly helps for better pipeline processing
    Invoke-SentinelOneApiRequest @functionParameters | & { process {
      $result = $_ | Select-Object -Property *,'groupId'
      $result.groupId = $_.id
      return $result
    }}
  }

  End {
    if ($SaveCommand) {
      Get-S1Command -Command $MyInvocation.MyCommand.Name -Parameters $params
    }
  }
}

function New-SentinelOneConfig {
  [CmdletBinding(SupportsShouldProcess = $true)]
  param(
    [Parameter(Mandatory=$true,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [System.UriBuilder] $ManagementUrl,

    [Parameter(Mandatory=$true,
      HelpMessage = 'The Api Version to use')]
    [float] $ApiVersion,

    [Parameter(Mandatory=$true,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [pscredential] $Credential,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Local app data folder name')]
    [string] $ConfigFolderName,

    [Parameter(Mandatory=$false,
      HelpMessage = 'XML config file name')]
    [string] $ConfigFileName
  )

  Begin {
    Write-Verbose "Creating config file"

    if (!$PSBoundParameters.ContainsKey('ConfigFolderName')) {
      $ConfigFolderName = "SentinelOne.Tools"
    }

    if (!$PSBoundParameters.ContainsKey('ConfigFileName')) {
      $ConfigFileName = "SentinelOne.Tools.xml"
    }

    # Define variables
    $configFileFolder = [io.path]::combine($ENV:LOCALAPPDATA, $ConfigFolderName)
    $configFile = [io.path]::combine($configFileFolder, $ConfigFileName)

    Write-Debug ("Received input:
    ManagementUrl: {0}
    ApiVersion: {1}
    Credential: {2}"
 -f $ManagementUrl, $ApiVersion, $Credential)

    Write-Verbose ("Config file location: {0}" -f $configFile)
    # Check if folder / config file exists
    if (!(Test-Path -Path $configFileFolder)) {
      # Folder does not exist
      Write-Verbose ("Config folder does not exist. Creating: {0}" -f $configFileFolder)
      if ($PSCmdlet.ShouldProcess((Split-Path -Parent -Path $configFileFolder), ("Creating folder '{0}'" -f (Split-Path -Leaf -Path $configFileFolder)))) {
        New-Item -Path $configFileFolder -ItemType Directory -Force | Out-Null
      } else {
        # Don't do anything
      }
    }
  }

  Process {

    $config = [pscustomobject]@{
      managementUrl = $ManagementUrl.Host
      apiVersion = $ApiVersion
      credential = $Credential
    }

    Write-Debug ("Config compiled: {0}" -f $config)

    # Create the config file
    if ($PSCmdlet.ShouldProcess((Split-Path -Parent -Path $configFile), ("Creating config file '{0}'" -f (Split-Path -Leaf -Path $configFile)))) {
      $config | Export-Clixml -Path $configFile -Force | Out-Null
    } else {
      # Don't do anything
    }

    Write-Verbose ("Config saved: {0}" -f $configFile)
  }

  End {
    $config = $null
    Remove-Variable -Name config
  }
}

# Function that returns a PSCustomObject containing SentinelOne site data

function Get-S1Site {
  <#
  .DESCRIPTION
    Get the Agents, and their data, that match the filter. This command gives the Agent ID, which you can use in other commands.
  .SYNOPSIS
    Get the Agents, and their data, that match the filter.
  .PARAMETER ManagementUrl
    The base URL for the customer's SentinelOne environment
  .PARAMETER Credential
    PSCredential Object containing the API Key in the Password field
 
  .PARAMETER Filter
    Filters to apply to the query in hashtable format.
    eg.
    $filters = @{
      groupIds = @(225494730938493804, 225494730938493915)
      sortBy = 'id'
      computerName__like = 'azm'
    }
    Get-S1Agent -Filter $filters
  .PARAMETER BySiteID
    Site ID
  .PARAMETER ByName
    Exact match of the site name
  .PARAMETER ByRegistrationToken
    Find sites matching a registration token
  .PARAMETER SiteType
    Site types: (Paid or Trial)
  .PARAMETER State
    Site states: (Active, Deleted, Expired)
  .OUTPUTS
    Returns a PSCustomObject containing the sites and its data
    Returns ErrorObject if an error is encountered
  .EXAMPLE
    Get-S1Site -ByType Trial
 
  #>

  [CmdletBinding(DefaultParameterSetName = 'All')]
  param(
        [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [string] $ManagementUrl,

        [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [PsCredential] $Credential,


        [Parameter(Mandatory=$false,
      HelpMessage = 'Filters to apply to the query in hashtable format.
      eg.
      $filters = @{
        groupIds = [225494730938493804, 225494730938493915]
        sortBy = "id"
        computerName__like = "azm"
      }
      Get-S1Agents -Filter $filters'
)]
    [hashtable] $Filter,

    [Alias('siteId')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'ID of the site',
      ValueFromPipelineByPropertyName = $true)]
    [string]$BySiteID,

    [Alias('siteName')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'Name of the SentinelOne site',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByName,

    [Alias('registrationToken')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'Find sites matching a registration token',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByRegistrationToken,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Find sites by site type (Paid or Trial)',
      ValueFromPipelineByPropertyName = $true)]
    [ValidateSet('Paid', 'Trial')]
    [string]$SiteType,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Site states: (Active, Deleted, Expired)',
      ValueFromPipelineByPropertyName = $true)]
    [ValidateSet('Active', 'Deleted', 'Expired')]
    [string]$State,

    [Parameter(Mandatory = $false)]
    [switch]
    $SaveCommand,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Pipeline handler',
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true,
      DontShow = $true)]
    [pscustomobject]$InputObject

    )

  Begin {
    # Use Max Page Size to reduce the number of requests
    $limit = 1000
    $ApiVersion = '2.1'
    $ApiEndpoint = [System.Text.StringBuilder]'sites'

    $Parameters = @()
    $Parameters += ("limit={0}" -f $limit)

    if ($Filter) {
      # An Agent filter was specified
      $Parameters += $Filter.keys | & { process {
        "$_={0}" -f $(if ($Filter.$_.GetType() -eq [Object[]]) {Join-String -Separator "," -InputObject $Filter.$_} else {$Filter.$_})
      }}
    }

    $ParamString = $Parameters -join '&'

    $null = $ApiEndpoint.Append("?")
    $null = $ApiEndpoint.Append($ParamString)
    $ApiEndpoint = $ApiEndpoint.ToString()

    if ($ManagementUrl) {
      $functionParameters += @{
        ManagementUrl = $ManagementUrl
      }
    }

    if ($Credential) {
      $functionParameters += @{
        Credential = $Credential
      }
    }
  }

  Process {

    # Filter options
    if ($SiteType) {
      $ApiEndpoint = [pscustomobject]@{GetSiteByType = $SiteType} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    }

    if ($State) {
      $ApiEndpoint = [pscustomobject]@{GetSiteByState = $State} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    }

    if ($ByName) { # Specific
      $ApiEndpoint = [pscustomobject]@{GetByName = $ByName} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif($ByRegistrationToken) { # Specific
      $ApiEndpoint = [pscustomobject]@{GetByRegistrationToken = $ByRegistrationToken} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif ($InputObject) { # Generic
      if ($InputObject.GetType() -eq [string]) {
        $ApiEndpoint = [pscustomobject]@{GetByName = $InputObject} | Get-S1Filter -ApiEndpoint $ApiEndpoint
      } else {
        $ApiEndpoint = Get-S1Filter -ApiEndpoint $ApiEndpoint -InputObject $InputObject
      }
    }

    $ApiEndpoint = $ApiEndpoint -replace "(\?|\&)(agentIds=[^&]+)",'$1'

    Write-Debug $ApiEndpoint

    # Save the command
    if ($SaveCommand) {
      $ApiEndpoint = $ApiEndpoint -replace "(\?|\&)(registrationToken=[^&]+)",'$1'
      $params += [pscustomobject]@{ApiEndpoint = $ApiEndpoint} | Get-S1CommandParameter -Invocation $MyInvocation
      return
    }

    # BySiteID? overwrite endpoint
    if ($BySiteID) { # Specific
      $ApiEndpoint = "sites/$BySiteID"
    }

    $functionParameters += @{
      ApiEndpoint = $ApiEndpoint
      ApiVersion  = $ApiVersion
    }

    # Error checking is all done in the following function so no extra error checking needs to be done.
    # Calling the function and returning the results instantly helps for better pipeline processing
    Invoke-SentinelOneApiRequest @functionParameters | & { process {
      if ($BySiteID) {
        $result = $_ | Select-Object -Property *,'siteId','siteName'
        $result.siteId = $_.id
        $result.siteName = $_.name
        return $result
      } else {
        $result = $_.sites | Select-Object -Property *,'siteId','siteName'
        $result | & { process {
          $_.siteId = $_.id
          $_.siteName = $_.name
          return $_
        }}
      }
    }}
  }

  End {
    if ($SaveCommand) {
      Get-S1Command -Command $MyInvocation.MyCommand.Name -Parameters $params
    }
  }
}

# Function that returns a PSCustomObject containing SentinelOne threat data

function Get-S1Threat {
  <#
  .DESCRIPTION
    Get data of threats that match the filter.
  .SYNOPSIS
    Get data of threats
  .PARAMETER ManagementUrl
    The base URL for the customer's SentinelOne environment
  .PARAMETER Credential
    PSCredential Object containing the API Key in the Password field
 
  .PARAMETER Filter
    Filters to apply to the query in hashtable format.
    eg.
    $filters = @{
      groupIds = @(225494730938493804, 225494730938493915)
      sortBy = 'id'
      computerName__like = 'azm'
    }
    Get-S1Threat -Filter $filters
  .OUTPUTS
    Returns a PSCustomObject containing threat data
    Returns ErrorObject if an error is encountered
  .EXAMPLE
    Get-S1Threat -ManagementUrl "apne1-1110-mssp.sentinelone.net" -Credential (Get-PSCredential)
 
  #>

  [CmdletBinding(DefaultParameterSetName = 'All')]
  param(
        [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [string] $ManagementUrl,

        [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [PsCredential] $Credential,


        [Parameter(Mandatory=$false,
      HelpMessage = 'Filters to apply to the query in hashtable format.
      eg.
      $filters = @{
        groupIds = @(225494730938493804, 225494730938493915)
        sortBy = "id"
        computerName__like = "azm"
      }
      Get-S1Agents -Filter $filters'
)]
    [hashtable] $Filter,

    [Alias('threatId')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'Threat ID',
      #ParameterSetName = 'ByThreatID',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByThreatID,

    # [Alias('agentId')]
    # [Parameter(Mandatory=$false,
    # HelpMessage = 'ID of the agent the threats belong to',
    # #ParameterSetName = 'ByAgentID',
    # ValueFromPipelineByPropertyName = $true)]
    # [string]$ByAgentID,

    # [Alias('groupId')]
    # [Parameter(Mandatory=$false,
    # HelpMessage = 'Group ID of the agents the threats belong to',
    # #ParameterSetName = 'ByGroupID',
    # ValueFromPipelineByPropertyName = $true)]
    # [string]$ByGroupID,

    # [Alias('siteId')]
    # [Parameter(Mandatory=$false,
    # HelpMessage = 'Site ID of the agents the threats belong to',
    # #ParameterSetName = 'BySiteID',
    # ValueFromPipelineByPropertyName = $true)]
    # [string]$BySiteID

    [Parameter(Mandatory = $false)]
    [switch]
    $SaveCommand,

    [Parameter(Mandatory = $false)]
    [switch]
    $NotMitigated,

    [Parameter(Mandatory = $false)]
    [switch]
    $FailedMitigation,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Pipeline handler',
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true,
      DontShow = $true)]
    [pscustomobject]$InputObject

    )

  Begin {
    # Use Max Page Size to reduce the number of requests
    $limit = 1000
    $ApiVersion = '2.1'
    $ApiEndpoint = [System.Text.StringBuilder]'threats'

    $Parameters = @()
    $Parameters += ("limit={0}" -f $limit)

    if ($Filter) {
      # An Agent filter was specified
      $Parameters += $Filter.keys | & { process {
        "$_={0}" -f $(if ($Filter.$_.GetType() -eq [Object[]]) {Join-String -Separator "," -InputObject $Filter.$_} else {$Filter.$_})
      }}
    }

    $ParamString = $Parameters -join '&'

    $null = $ApiEndpoint.Append("?")
    $null = $ApiEndpoint.Append($ParamString)
    $ApiEndpoint = $ApiEndpoint.ToString()

    if ($ManagementUrl) {
      $functionParameters += @{
        ManagementUrl = $ManagementUrl
      }
    }

    if ($Credential) {
      $functionParameters += @{
        Credential = $Credential
      }
    }
  }

  Process {
    if ($ByThreatID) {
      $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)ids=', '$1threatIds='
      $ApiEndpoint = [pscustomobject]@{GetByThreatId = $ByThreatID} | Get-S1Filter -ApiEndpoint $ApiEndpoint
      $ApiEndpoint = $ApiEndpoint -replace 'threatIds', 'ids'
    } elseif ($InputObject) {
      $ApiEndpoint = Get-S1Filter -ApiEndpoint $ApiEndpoint -InputObject $InputObject
    }

    $functionParameters += @{
      ApiEndpoint = $ApiEndpoint
      ApiVersion  = $ApiVersion
    }

    # Save the command
    if ($SaveCommand) {
      $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)ids=', '$1threatIds='
      if ($ByThreatID -and ($ApiEndpoint -match "(\?|\&)(threatIds=[^&]+)")) {
        if ($Matches[1] -eq '?') {
          $ApiEndpoint = $ApiEndpoint -replace "(\?|\&)(threatIds=[^&]+)",'?'
        } else {
          $ApiEndpoint = $ApiEndpoint -replace "(\?|\&)(threatIds=[^&]+)",''
        }
      }
      $params += [pscustomobject]@{ApiEndpoint = $ApiEndpoint} | Get-S1CommandParameter -Invocation $MyInvocation
      return
    }

    # Error checking is all done in the following function so no extra error checking needs to be done.
    # Calling the function and returning the results instantly helps for better pipeline processing
    Invoke-SentinelOneApiRequest @functionParameters | & { process {
      $result = $_ | Select-Object -Property *,'threatId','storylineId','agentId','siteId','groupId'
      $result.threatId = $_.id
      $result.storylineId = $_.threatInfo.storyline
      $result.agentId = $_.agentRealtimeInfo.agentId
      $result.siteId = $_.agentRealtimeInfo.siteId
      $result.groupId = $_.agentRealtimeInfo.groupId
      return $result
    }} | & { process {
      if ($NotMitigated -or $FailedMitigation) {
        if ($NotMitigated -and ($_.threatInfo.mitigationStatus -eq 'not_mitigated')) {
          return $_
        } elseif ($FailedMitigation -and ($_.mitigationStatus.status -match 'failed')) {
          return $_
        }
      } else {
        return $_
      }
    }}
  }

  End {
    if ($SaveCommand) {
      Get-S1Command -Command $MyInvocation.MyCommand.Name -Parameters $params
    }
  }
}

function Get-S1User {
  <#
  .DESCRIPTION
    Get a list of users or a given user by ID.
  .SYNOPSIS
    Get a list of users or a given user by ID.
  .PARAMETER ManagementUrl
    The base URL for the customer's SentinelOne environment
  .PARAMETER Credential
    PSCredential Object containing the API Key in the Password field
 
  .PARAMETER Filter
    Filters to apply to the query in hashtable format.
    eg.
    $filters = @{
      groupIds = @(225494730938493804, 225494730938493915)
      sortBy = 'id'
    }
    Get-S1User -Filter $filters
  .PARAMETER ByUserID
    ID of the user
  .PARAMETER ByUserEmail
    Email of the user to find
  .OUTPUTS
    Returns a PSCustomObject containing the User(s)
    Returns ErrorObject if an error is encountered
  .EXAMPLE
    Get-S1User -ManagementUrl "apne1-1110-mssp.sentinelone.net" -Credential (Get-PSCredential)
 
  #>

  [CmdletBinding(DefaultParameterSetName = 'All')]
  param(
        [Parameter(Mandatory=$false,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [string] $ManagementUrl,

        [Parameter(Mandatory=$false,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [PsCredential] $Credential,


        [Parameter(Mandatory=$false,
      HelpMessage = 'Filters to apply to the query in hashtable format.
      eg.
      $filters = @{
        groupIds = @(225494730938493804, 225494730938493915)
        sortBy = "id"
      }
      Get-S1User -Filter $filters'
)]
    [hashtable] $Filter,

    [Alias('userId')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'ID of the user',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByUserID,

    [Alias('email')]
    [Parameter(Mandatory=$false,
      HelpMessage = 'Email of the user',
      ValueFromPipelineByPropertyName = $true)]
    [string]$ByUserEmail,

    [Parameter(Mandatory = $false)]
    [switch]
    $SaveCommand,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Pipeline handler',
      ValueFromPipeline = $true,
      ValueFromRemainingArguments = $true,
      DontShow = $true)]
    [pscustomobject]$InputObject
    )

  Begin {
    # Use Max Page Size to reduce the number of requests
    $limit = 1000
    $ApiVersion = '2.1'
    $ApiEndpoint = [System.Text.StringBuilder]'users'

    $Parameters = @()
    $Parameters += ("limit={0}" -f $limit)

    if ($Filter) {
      # An Agent filter was specified
      $Parameters += $Filter.keys | & { process {
        "$_={0}" -f $(if ($Filter.$_.GetType() -eq [Object[]]) {Join-String -Separator "," -InputObject $Filter.$_} else {$Filter.$_})
      }}
    }

    $ParamString = $Parameters -join '&'

    $null = $ApiEndpoint.Append("?")
    $null = $ApiEndpoint.Append($ParamString)
    $ApiEndpoint = $ApiEndpoint.ToString()

    if ($ManagementUrl) {
      $functionParameters += @{
        ManagementUrl = $ManagementUrl
      }
    }

    if ($Credential) {
      $functionParameters += @{
        Credential = $Credential
      }
    }
  }

  Process {

    if ($ByUserID) { # Specific
      $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)ids=', '$1userIds='
      $ApiEndpoint = [pscustomobject]@{GetByUserId = $ByUserID} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif ($ByUserEmail) { # Specific
      $ApiEndpoint = [pscustomobject]@{GetByUserEmail = $ByUserEmail} | Get-S1Filter -ApiEndpoint $ApiEndpoint
    } elseif ($InputObject) { # Generic
      if ($InputObject.GetType() -eq [int]) { # Strings parsed through pipeline, assume they are names
        $ApiEndpoint = [pscustomobject]@{GetByUserId = $InputObject} | Get-S1Filter -ApiEndpoint $ApiEndpoint
      } else {
        $ApiEndpoint = Get-S1Filter -ApiEndpoint $ApiEndpoint -InputObject $InputObject
      }
    }

    $ApiEndpoint = $ApiEndpoint -replace 'userIds', 'ids'

    # Build
    $functionParameters += @{
      ApiEndpoint = $ApiEndpoint
      ApiVersion  = $ApiVersion
    }

    # Save the command
    if ($SaveCommand) {
      $ApiEndpoint = $ApiEndpoint -replace '(\?|\&)ids=', '$1userIds='
      $params += [pscustomobject]@{ApiEndpoint = $ApiEndpoint} | Get-S1CommandParameter -Invocation $MyInvocation
      return
    }

    # Error checking is all done in the following function so no extra error checking needs to be done.
    # Calling the function and returning the results instantly helps for better pipeline processing
    Invoke-SentinelOneApiRequest @functionParameters | & { process {
      $data = $_
      $result = $_ | Select-Object -Property *,'userId','roleId','accountId','siteId'
      $result.userId = $_.id
      $result.roleId = $_.scopeRoles[0].roleId
      switch ($_.scope) {
        'account' { $result.accountId = $data.scopeRoles[0].id }
        'site' { $result.siteId = $data.scopeRoles[0].id }
      }
      return $result
    }}
  }

  End {
    if ($SaveCommand) {
      Get-S1Command -Command $MyInvocation.MyCommand.Name -Parameters $params
    }
  }
}

function New-SentinelOneConfig {
  [CmdletBinding(SupportsShouldProcess = $true)]
  param(
    [Parameter(Mandatory=$true,
      HelpMessage = 'The base URL for the customers SentinelOne environment')]
    [System.UriBuilder] $ManagementUrl,

    [Parameter(Mandatory=$true,
      HelpMessage = 'The Api Version to use')]
    [float] $ApiVersion,

    [Parameter(Mandatory=$true,
      HelpMessage = 'PSCredential Object containing the API Key in the Password field')]
    [pscredential] $Credential,

    [Parameter(Mandatory=$false,
      HelpMessage = 'Local app data folder name')]
    [string] $ConfigFolderName,

    [Parameter(Mandatory=$false,
      HelpMessage = 'XML config file name')]
    [string] $ConfigFileName
  )

  Begin {
    Write-Verbose "Creating config file"

    if (!$PSBoundParameters.ContainsKey('ConfigFolderName')) {
      $ConfigFolderName = "SentinelOne.Tools"
    }

    if (!$PSBoundParameters.ContainsKey('ConfigFileName')) {
      $ConfigFileName = "SentinelOne.Tools.xml"
    }

    # Define variables
    $configFileFolder = [io.path]::combine($ENV:LOCALAPPDATA, $ConfigFolderName)
    $configFile = [io.path]::combine($configFileFolder, $ConfigFileName)

    Write-Debug ("Received input:
    ManagementUrl: {0}
    ApiVersion: {1}
    Credential: {2}"
 -f $ManagementUrl, $ApiVersion, $Credential)

    Write-Verbose ("Config file location: {0}" -f $configFile)
    # Check if folder / config file exists
    if (!(Test-Path -Path $configFileFolder)) {
      # Folder does not exist
      Write-Verbose ("Config folder does not exist. Creating: {0}" -f $configFileFolder)
      if ($PSCmdlet.ShouldProcess((Split-Path -Parent -Path $configFileFolder), ("Creating folder '{0}'" -f (Split-Path -Leaf -Path $configFileFolder)))) {
        New-Item -Path $configFileFolder -ItemType Directory -Force | Out-Null
      } else {
        # Don't do anything
      }
    }
  }

  Process {

    $config = [pscustomobject]@{
      managementUrl = $ManagementUrl.Host
      apiVersion = $ApiVersion
      credential = $Credential
    }

    Write-Debug ("Config compiled: {0}" -f $config)

    # Create the config file
    if ($PSCmdlet.ShouldProcess((Split-Path -Parent -Path $configFile), ("Creating config file '{0}'" -f (Split-Path -Leaf -Path $configFile)))) {
      $config | Export-Clixml -Path $configFile -Force | Out-Null
    } else {
      # Don't do anything
    }

    Write-Verbose ("Config saved: {0}" -f $configFile)
  }

  End {
    $config = $null
    Remove-Variable -Name config
  }
}

Export-ModuleMember -Function Get-S1Activity, Get-S1ActivityType, Get-S1Agent, Get-S1AgentCount, Invoke-SentinelOneApiRequest, Get-S1Group, New-SentinelOneConfig, Get-S1Site, Get-S1Threat, Get-S1User, New-SentinelOneConfig

# SIG # Begin signature block
# MIIoHgYJKoZIhvcNAQcCoIIoDzCCKAsCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCee9kCbR+LlRMR
# n7Fm+cHGU8hVcA1lXpT/I6JhTb8T9KCCISEwggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwggauMIIElqADAgECAhAHNje3JFR82Ees/ShmKl5bMA0GCSqG
# SIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yMjAzMjMwMDAwMDBaFw0zNzAzMjIyMzU5NTlaMGMx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMy
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcg
# Q0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDGhjUGSbPBPXJJUVXH
# JQPE8pE3qZdRodbSg9GeTKJtoLDMg/la9hGhRBVCX6SI82j6ffOciQt/nR+eDzMf
# UBMLJnOWbfhXqAJ9/UO0hNoR8XOxs+4rgISKIhjf69o9xBd/qxkrPkLcZ47qUT3w
# 1lbU5ygt69OxtXXnHwZljZQp09nsad/ZkIdGAHvbREGJ3HxqV3rwN3mfXazL6IRk
# tFLydkf3YYMZ3V+0VAshaG43IbtArF+y3kp9zvU5EmfvDqVjbOSmxR3NNg1c1eYb
# qMFkdECnwHLFuk4fsbVYTXn+149zk6wsOeKlSNbwsDETqVcplicu9Yemj052FVUm
# cJgmf6AaRyBD40NjgHt1biclkJg6OBGz9vae5jtb7IHeIhTZgirHkr+g3uM+onP6
# 5x9abJTyUpURK1h0QCirc0PO30qhHGs4xSnzyqqWc0Jon7ZGs506o9UD4L/wojzK
# QtwYSH8UNM/STKvvmz3+DrhkKvp1KCRB7UK/BZxmSVJQ9FHzNklNiyDSLFc1eSuo
# 80VgvCONWPfcYd6T/jnA+bIwpUzX6ZhKWD7TA4j+s4/TXkt2ElGTyYwMO1uKIqjB
# Jgj5FBASA31fI7tk42PgpuE+9sJ0sj8eCXbsq11GdeJgo1gJASgADoRU7s7pXche
# MBK9Rp6103a50g5rmQzSM7TNsQIDAQABo4IBXTCCAVkwEgYDVR0TAQH/BAgwBgEB
# /wIBADAdBgNVHQ4EFgQUuhbZbU2FL3MpdpovdYxqII+eyG8wHwYDVR0jBBgwFoAU
# 7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1UdJQQMMAoG
# CCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0cDovL29j
# c3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0cy5kaWdp
# Y2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8EPDA6MDig
# NqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9v
# dEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEwDQYJKoZI
# hvcNAQELBQADggIBAH1ZjsCTtm+YqUQiAX5m1tghQuGwGC4QTRPPMFPOvxj7x1Bd
# 4ksp+3CKDaopafxpwc8dB+k+YMjYC+VcW9dth/qEICU0MWfNthKWb8RQTGIdDAiC
# qBa9qVbPFXONASIlzpVpP0d3+3J0FNf/q0+KLHqrhc1DX+1gtqpPkWaeLJ7giqzl
# /Yy8ZCaHbJK9nXzQcAp876i8dU+6WvepELJd6f8oVInw1YpxdmXazPByoyP6wCeC
# RK6ZJxurJB4mwbfeKuv2nrF5mYGjVoarCkXJ38SNoOeY+/umnXKvxMfBwWpx2cYT
# gAnEtp/Nh4cku0+jSbl3ZpHxcpzpSwJSpzd+k1OsOx0ISQ+UzTl63f8lY5knLD0/
# a6fxZsNBzU+2QJshIUDQtxMkzdwdeDrknq3lNHGS1yZr5Dhzq6YBT70/O3itTK37
# xJV77QpfMzmHQXh6OOmc4d0j/R0o08f56PGYX/sr2H7yRp11LB4nLCbbbxV7HhmL
# NriT1ObyF5lZynDwN7+YAN8gFk8n+2BnFqFmut1VwDophrCYoCvtlUG3OtUVmDG0
# YgkPCr2B2RP+v6TR81fZvAT6gt4y3wSJ8ADNXcL50CN/AAvkdgIm2fBldkKmKYcJ
# RyvmfxqkhQ/8mJb2VVQrH4D6wPIOK+XW+6kvRBVK5xMOHds3OBqhK/bt1nz8MIIG
# sDCCBJigAwIBAgIQCK1AsmDSnEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBiMQsw
# CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu
# ZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQw
# HhcNMjEwNDI5MDAwMDAwWhcNMzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJVUzEX
# MBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0
# ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIICIjAN
# BgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1M4zr
# PYGXcMW7xIUmMJ+kjmjYXPXrNCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZwZHM
# gQM+TXAkZLON4gh9NH1MgFcSa0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI8Irg
# nQnAZaf6mIBJNYc9URnokCF4RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGiTUyC
# EUhSaN4QvRRXXegYE2XFf7JPhSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLmysL0
# p6MDDnSlrzm2q2AS4+jWufcx4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3SvUQa
# khCBj7A7CdfHmzJawv9qYFSLScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tvk2E0
# XLyTRSiDNipmKF+wc86LJiUGsoPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+960I
# HnWmZcy740hQ83eRGv7bUKJGyGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3sMJN2
# FKZbS110YU0/EpF23r9Yy3IQKUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FKPkBH
# X8mBUHOFECMhWWCKZFTBzCEa6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1Hs/q2
# 7IwyCQLMbDwMVhECAwEAAaOCAVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYD
# VR0OBBYEFGg34Ou2O/hfEYb7/mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LScV1k
# TN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcD
# AzB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2lj
# ZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29t
# L0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0
# cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmww
# HAYDVR0gBBUwEzAHBgVngQwBAzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQADggIB
# ADojRD2NCHbuj7w6mdNW4AIapfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L/Z6j
# fCbVN7w6XUhtldU/SfQnuxaBRVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHVUHmI
# moqKwba9oUgYftzYgBoRGRjNYZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rdKOtf
# JqGVWEjVGv7XJz/9kNF2ht0csGBc8w2o7uCJob054ThO2m67Np375SFTWsPK6Wrx
# oj7bQ7gzyE84FJKZ9d3OVG3ZXQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43Nb3Y3
# LIU/Gs4m6Ri+kAewQ3+ViCCCcPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4ZXDlx
# 4b6cpwoG1iZnt5LmTl/eeqxJzy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvmoLr9
# Oj9FpsToFpFSi0HASIRLlk2rREDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8y4+I
# Cw2/O/TOHnuO77Xry7fwdxPm5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMMB0ug
# 0wcCampAMEhLNKhRILutG4UI4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+FSCH5
# Vzu0nAPthkX0tGFuv2jiJmCG6sivqf6UHedjGzqGVnhOMIIGwDCCBKigAwIBAgIQ
# DE1pckuU+jwqSj0pB4A9WjANBgkqhkiG9w0BAQsFADBjMQswCQYDVQQGEwJVUzEX
# MBUGA1UEChMORGlnaUNlcnQsIEluYy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0
# ZWQgRzQgUlNBNDA5NiBTSEEyNTYgVGltZVN0YW1waW5nIENBMB4XDTIyMDkyMTAw
# MDAwMFoXDTMzMTEyMTIzNTk1OVowRjELMAkGA1UEBhMCVVMxETAPBgNVBAoTCERp
# Z2lDZXJ0MSQwIgYDVQQDExtEaWdpQ2VydCBUaW1lc3RhbXAgMjAyMiAtIDIwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDP7KUmOsap8mu7jcENmtuh6BSF
# dDMaJqzQHFUeHjZtvJJVDGH0nQl3PRWWCC9rZKT9BoMW15GSOBwxApb7crGXOlWv
# M+xhiummKNuQY1y9iVPgOi2Mh0KuJqTku3h4uXoW4VbGwLpkU7sqFudQSLuIaQyI
# xvG+4C99O7HKU41Agx7ny3JJKB5MgB6FVueF7fJhvKo6B332q27lZt3iXPUv7Y3U
# TZWEaOOAy2p50dIQkUYp6z4m8rSMzUy5Zsi7qlA4DeWMlF0ZWr/1e0BubxaompyV
# R4aFeT4MXmaMGgokvpyq0py2909ueMQoP6McD1AGN7oI2TWmtR7aeFgdOej4TJEQ
# ln5N4d3CraV++C0bH+wrRhijGfY59/XBT3EuiQMRoku7mL/6T+R7Nu8GRORV/zbq
# 5Xwx5/PCUsTmFntafqUlc9vAapkhLWPlWfVNL5AfJ7fSqxTlOGaHUQhr+1NDOdBk
# +lbP4PQK5hRtZHi7mP2Uw3Mh8y/CLiDXgazT8QfU4b3ZXUtuMZQpi+ZBpGWUwFjl
# 5S4pkKa3YWT62SBsGFFguqaBDwklU/G/O+mrBw5qBzliGcnWhX8T2Y15z2LF7OF7
# ucxnEweawXjtxojIsG4yeccLWYONxu71LHx7jstkifGxxLjnU15fVdJ9GSlZA076
# XepFcxyEftfO4tQ6dwIDAQABo4IBizCCAYcwDgYDVR0PAQH/BAQDAgeAMAwGA1Ud
# EwEB/wQCMAAwFgYDVR0lAQH/BAwwCgYIKwYBBQUHAwgwIAYDVR0gBBkwFzAIBgZn
# gQwBBAIwCwYJYIZIAYb9bAcBMB8GA1UdIwQYMBaAFLoW2W1NhS9zKXaaL3WMaiCP
# nshvMB0GA1UdDgQWBBRiit7QYfyPMRTtlwvNPSqUFN9SnDBaBgNVHR8EUzBRME+g
# TaBLhklodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRS
# U0E0MDk2U0hBMjU2VGltZVN0YW1waW5nQ0EuY3JsMIGQBggrBgEFBQcBAQSBgzCB
# gDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFgGCCsGAQUF
# BzAChkxodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVk
# RzRSU0E0MDk2U0hBMjU2VGltZVN0YW1waW5nQ0EuY3J0MA0GCSqGSIb3DQEBCwUA
# A4ICAQBVqioa80bzeFc3MPx140/WhSPx/PmVOZsl5vdyipjDd9Rk/BX7NsJJUSx4
# iGNVCUY5APxp1MqbKfujP8DJAJsTHbCYidx48s18hc1Tna9i4mFmoxQqRYdKmEIr
# UPwbtZ4IMAn65C3XCYl5+QnmiM59G7hqopvBU2AJ6KO4ndetHxy47JhB8PYOgPvk
# /9+dEKfrALpfSo8aOlK06r8JSRU1NlmaD1TSsht/fl4JrXZUinRtytIFZyt26/+Y
# siaVOBmIRBTlClmia+ciPkQh0j8cwJvtfEiy2JIMkU88ZpSvXQJT657inuTTH4YB
# ZJwAwuladHUNPeF5iL8cAZfJGSOA1zZaX5YWsWMMxkZAO85dNdRZPkOaGK7DycvD
# +5sTX2q1x+DzBcNZ3ydiK95ByVO5/zQQZ/YmMph7/lxClIGUgp2sCovGSxVK05iQ
# RWAzgOAj3vgDpPZFR+XOuANCR+hBNnF3rf2i6Jd0Ti7aHh2MWsgemtXC8MYiqE+b
# vdgcmlHEL5r2X6cnl7qWLoVXwGDneFZ/au/ClZpLEQLIgpzJGgV8unG1TnqZbPTo
# ntRamMifv427GFxD9dAq6OJi7ngE273R+1sKqHB+8JeEeOMIA11HLGOoJTiXAdI/
# Otrl5fbmm9x+LMz/F0xNAKLY1gEOuIvu5uByVYksJxlh9ncBjDCCB2IwggVKoAMC
# AQICEA6ZYkUs3x6FUkpZyx3fNW8wDQYJKoZIhvcNAQELBQAwaTELMAkGA1UEBhMC
# VVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBU
# cnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENBMTAe
# Fw0yMjExMTEwMDAwMDBaFw0yMzExMTAyMzU5NTlaMGcxCzAJBgNVBAYTAkFVMREw
# DwYDVQQIEwhWaWN0b3JpYTEVMBMGA1UEBxMMTm90dGluZyBIaWxsMRYwFAYDVQQK
# Ew1JUFNlYyBQdHkgTHRkMRYwFAYDVQQDEw1JUFNlYyBQdHkgTHRkMIICIjANBgkq
# hkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuJ4EsEL1x5o2E2m4pFTCeVzGv5URXuSd
# Q25eyXUbMGb7VsTbft4MYM6ySZe7I1Yk7Jm/rsSsjDuud9XbvLJB3qX/kdVCFMLn
# y2On85sFUQ67ZWHGh5ub9tIJzkJN7BlJ0u2Rw09AeLAArF3yqeQffu/YegMSH8rY
# eb8DLvFr9lxKjoWXg5khH3yXWaI0N0g/FHKWL9URn6HSStY90y4ilQCcjwjwA8Vp
# gIU9eCDj4kagwdU5ZvOazbzQXJqvzMB09ccLegf2MrPoMseRSksL0BBo5kkq5jiw
# Fc4HIf2/RYrjfVWtFLq33tvMDifpI/ZuhiBooaAEe3OA10s8+A4Ps3OkSP2hd/Hq
# UXGQZY1FN3fDRworMkEaaqQioeaaSePAIwdGTz2Z2aCY4b5taHWiOM/5+jhCr6Gq
# S8TXI1ODtZl2V11/KfKJDjiTes2lQ4zShHNAg40/aTOQxmsvv1zwlM9Dqf8GRDC1
# OThWrPc40s6RztXa9CnC3EQgxIUQs/qQwkToS3dJGcr/bsCmVCHPQKDjHYIHbU8n
# CMjSaG1YDIfdRW9MyyAFghtzjDDEyyN+LJCOdEnq4WgiaNIHNDsYABTAy9Nc2u4u
# VmQE387ZqbAww2Doo/eiEnPU5y1JgXvxhqgdL2Fg20gXagfBqysTuuExuh/AoSuM
# TEO02MRZ+78CAwEAAaOCAgYwggICMB8GA1UdIwQYMBaAFGg34Ou2O/hfEYb7/mF7
# CIhl9E5CMB0GA1UdDgQWBBTZSP/AY1Bcm0dUbWSS+LJDbsyIHzAOBgNVHQ8BAf8E
# BAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZN
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNp
# Z25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0
# LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5
# NlNIQTM4NDIwMjFDQTEuY3JsMD4GA1UdIAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYB
# BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzCBlAYIKwYBBQUHAQEE
# gYcwgYQwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBcBggr
# BgEFBQcwAoZQaHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1
# c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcnQwDAYDVR0T
# AQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAgEAFQZrq6Y5WS74dGqSjbSXHqiQeQhc
# emYWRNkPOnA4duyDdKNerNSkYDhqunW3igVhLkjYzV3rxPceL67+mUdy4ppf9g1Q
# e8f6cM/wELnKmXH/aCGBVvBEjVJnt3LTQLGUGNu28Np1Hdfg7H0SqRlSuvLCaibr
# C87bRHgzCLGixw/Bo2d/SuKze2EXTtt85DLvx7TCOE894bUuhaHUnz207+SUhRnm
# W4pvHel8wJWfDN7QXANFvaV667cOlPZ9Kz2Jrxgf389Pj2XPrfIL/oH3U5OY6Q0/
# dHpFrT8s30MpG5iFQKbYn8W4790Gt/tjJVAFhMxYfnylT+ppemmvrltG/0qah903
# /IxMgSVI9I/23Gs83i7ppeCNnncF/5JRfD2qkQqKRHaJx/6rMjV0MDmV+m16bllZ
# jEfnXIxPyB1P4tJEwiGtbKhuEX9WbdE5msxy64FFU/4Ucxa8dIjllcr1MMfwJttX
# QDLtBYXDFrtb4r26FGRe3M+mjmZ0skzWPEuZpnjR0wK8Ep8HzEdB5WzLB/7lf70n
# YEWfuE1CjviDRz9hesjHZZ7nvPNXuraIRqPdNWnI8eJ7sT+jnL9nP3/h/uDvuHZx
# LAUBvRo6iNOOmO34/TZNCV0fBpZEBUBLJTBmFZoBTMV8z156RL7QXjCK1cLgXuCU
# OfJLfjrNc5RZvtAxggZTMIIGTwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQK
# Ew5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBD
# b2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEzODQgMjAyMSBDQTECEA6ZYkUs3x6FUkpZ
# yx3fNW8wDQYJYIZIAWUDBAIBBQCggYQwGAYKKwYBBAGCNwIBDDEKMAigAoAAoQKA
# ADAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYK
# KwYBBAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgQPFpLOb/j1vVptwzsoK/5HDa9cdO
# QUW3S19ptgiSW1owDQYJKoZIhvcNAQEBBQAEggIAbiBJU3FnGcwtPCNkmUdY5JgA
# r9TIPSamBtBMeZSInv7nnVC8Az3g3pxovqrt8leJG4SwC4cDrfdjIBv4Aiy/x2Od
# Up/N7sApI2BFpQvMK7nS+90DdxTyFDyqMEuOkQrjU9rP8UAkU51F9Oam1MYndzXX
# 8V7D5Vpnx1cFEd5iKEGvN1MZMS1sdDnIPFGrizG4iYTknQoaehLPTpubNHwwCh7r
# Um3Vbx5ce7pjzW9mTUmaHxkQMscrZ3s1Yhf4KW7rgPhriJBKdNkMYh1HggRxM1El
# ulBaAJJy9T+CKFDFSgOXnwXpvcl4PJ4Ldf716Iz6C7RHT6Tx3mTaXOtA6TK4gWkv
# DH+gF+Mdpg+zI4QOJApYhJ1kTbCOfFySOXuUnJQZGXDuU1ncVnJ3Kbbirh1BTpxi
# Pssi9EHcmxM7IAw/kVdWXEjD6/gJJkXgUEMSRQ4lR9cUWbhh07tNlX5hmBbaCnwv
# IdqvELlUcjxCukgRpIq9qCNRMsnCAh12xaM57XVZwPwS/bcMuWfGGD2NvqOvtIaU
# T5j/Vcam0OImmG+PQbPKZO13QDdMkld6TQeQEP2ujxweO0RiE2OlHg8KxNtTR4Db
# 702sIi1yAnqOv3hEL0g0SSHjlzl4ZEgtEz7ydNU3EhH0fRQIx0V/nTy/qJ7Izn9l
# +hl8sShS+nEgGyCH7+KhggMgMIIDHAYJKoZIhvcNAQkGMYIDDTCCAwkCAQEwdzBj
# MQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xOzA5BgNVBAMT
# MkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEyNTYgVGltZVN0YW1waW5n
# IENBAhAMTWlyS5T6PCpKPSkHgD1aMA0GCWCGSAFlAwQCAQUAoGkwGAYJKoZIhvcN
# AQkDMQsGCSqGSIb3DQEHATAcBgkqhkiG9w0BCQUxDxcNMjMwNjMwMDczNzA0WjAv
# BgkqhkiG9w0BCQQxIgQgX42O2yX44e5sRBkOxM6j7Kdz7s2pcKeoT5f0/0/Hjcow
# DQYJKoZIhvcNAQEBBQAEggIAhIe2PHyT0zxMLiXR5se9NQ4E7rMcGU5STnwI6KE1
# 71PQt7SW5iEW/vQFEJqNflKf5V4ueqnlppxcSuqn+zYARBeAkL05Mjew4L7awFaW
# 0r02ocJKSZz/jBSUFF/HX7CjDeN7yp87FfacvWyWyuT2W9F0RW9Ufp/9cDpDMFjn
# DLALm9M6PvJMTkVIMBA8XK9yCkvC4PWwe502N3Si6a18F9aPzHDwg1q9Am1j9IUF
# Kv4tIk2zJionQ0kYOo+yAOdYszDrHuj21+5fI+DgzfAQAgfSs3ojaJuLMF2/eVZ+
# mJeTpMzxB5ZfPtSf22e1Va+ETZV385GFIJorG91wdwSUHTmNoR4KfbTeDgThx1vi
# vRJ70ONrd1mRdz1dc1ZBg1VHpRQkSHyoUlsBp/3VEFU0p3/nMrQCLb8x3xcoBR61
# x5g3wnsMWLmQnlGdxW+h4qP5mK8BWA1H2XR/KcOGmmYIqeltUBE/r7veOoWzQRqE
# DOWAere+kdYJNjRVmNFAy86+3yZG3haXlzaYzMXQ+YR2s/9Jho+TCGULIzq5f9lx
# bjfbLa7VyjPGYNkVZi7wBrkCT76bqA+XtuFC06YX/LgMNJtc5q9vfhw4SRO/snUb
# fekI8pWU3hQk/QAk5LQJQ8fQqOYiqY4f3VGKp9d4URn7+997W/pny+CR5gdYogxL
# LPI=
# SIG # End signature block