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 |