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 Convert-SdlPowerQueryResponseToHash { param ( [Parameter(Mandatory = $true)] [psobject]$PqResponse ) try { # Get column names $columnNames = $PqResponse.columns | ForEach-Object { $_.name } # Initialize result array $result = @() # Convert each row to a hashtable foreach ($row in $PqResponse.values) { $rowHash = @{} for ($i = 0; $i -lt $columnNames.Count; $i++) { $rowHash[$columnNames[$i]] = $row[$i] } $result += [PSCustomObject]$rowHash } # Create response object with metadata and results $response = @{ Status = $PqResponse.status MatchingEvents = $PqResponse.matchingEvents OmittedEvents = $PqResponse.omittedEvents Results = $result } return $response } catch { Write-Error "Failed to parse API response: $_" return $null } } 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 that returns a PSCustomObject containing SentinelOne site data function Get-S1Account { <# .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 ByAxccountID Account ID .PARAMETER ByName Exact match of the Account name .PARAMETER AccountType 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('accountId')] [Parameter(Mandatory=$false, HelpMessage = 'ID of the account', ValueFromPipelineByPropertyName = $true)] [string]$ByAccountID, [Alias('accountName')] [Parameter(Mandatory=$false, HelpMessage = 'Name of the SentinelOne account', ValueFromPipelineByPropertyName = $true)] [string]$ByName, [Parameter(Mandatory=$false, HelpMessage = 'Find accounts by account type (Paid or Trial)', ValueFromPipelineByPropertyName = $true)] [ValidateSet('Paid', 'Trial')] [string]$AccountType, [Parameter(Mandatory=$false, HelpMessage = 'Account 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]'accounts' $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 ($AccountType) { $ApiEndpoint = [pscustomobject]@{GetAccountByType = $AccountType} | Get-S1Filter -ApiEndpoint $ApiEndpoint } if ($State) { $ApiEndpoint = [pscustomobject]@{GetAccountByState = $State} | Get-S1Filter -ApiEndpoint $ApiEndpoint } if ($ByName) { # Specific $ApiEndpoint = [pscustomobject]@{GetByName = $ByName} | 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 "(\?|\&)(accountIds=[^&]+)",'$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 ($ByAccountID) { # Specific $ApiEndpoint = "accounts/$ByAccountID" } $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 ($ByAccountID) { $result = $_ | Select-Object -Property *,'accountId','accountName' $result.accountId = $_.id $result.accountName = $_.name return $result } else { Write-Verbose ('Returning all accounts') $result = $_ | Select-Object -Property *,'accountId','accountName' $result | & { process { $_.accountId = $_.id $_.accountName = $_.name return $_ }} } }} } End { if ($SaveCommand) { Get-S1Command -Command $MyInvocation.MyCommand.Name -Parameters $params } } } 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 MyAccountID Account ID .PARAMETER BySiteID Site ID .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('accountId')] [Parameter(Mandatory = $false, HelpMessage = 'Site ID of the agents the threats belong to', #ParameterSetName = 'BySiteID', ValueFromPipelineByPropertyName = $true)] [string]$ByAccountID, [Alias('siteId')] [Parameter(Mandatory = $false, HelpMessage = 'Site ID of the agents the threats belong to', #ParameterSetName = 'BySiteID', ValueFromPipelineByPropertyName = $true)] [string]$BySiteID, [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 { # Filter by Account and/or Site if specified if ($ByAccountId) { $ApiEndpoint = [pscustomobject]@{accountId = $ByAccountId } | Get-S1Filter -ApiEndpoint $ApiEndpoint } if ($BySiteID) { $ApiEndpoint = [pscustomobject]@{siteId = $BySiteID } | Get-S1Filter -ApiEndpoint $ApiEndpoint } 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 MyAccountID Account ID .PARAMETER BySiteID Site ID .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, [Alias('accountId')] [Parameter(Mandatory = $false, HelpMessage = 'Site ID of the agents the threats belong to', #ParameterSetName = 'BySiteID', ValueFromPipelineByPropertyName = $true)] [string]$ByAccountID, [Alias('siteId')] [Parameter(Mandatory = $false, HelpMessage = 'Site ID of the agents the threats belong to', #ParameterSetName = 'BySiteID', ValueFromPipelineByPropertyName = $true)] [string]$BySiteID, [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 { # Filter by Account and/or Site if specified if ($ByAccountId) { $ApiEndpoint = [pscustomobject]@{accountId = $ByAccountId } | Get-S1Filter -ApiEndpoint $ApiEndpoint } if ($BySiteID) { $ApiEndpoint = [pscustomobject]@{siteId = $BySiteID } | Get-S1Filter -ApiEndpoint $ApiEndpoint } 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-SdlApiRequest { <# .SYNOPSIS Invoke the Singularity DataLake (SDL) API .DESCRIPTION This function is intended to be called by other functions for specific resources/interactions .PARAMETER SdlConsoleUri Base API URL for the API Call .PARAMETER Credential PSCredential Object with the API Key stored in the Password property of the object. .PARAMETER Scope S1 Scope specifier. If the supplied credential is a SentinelOne API token with access to multiple accounts or sites, this must be supplied. Without this no data is returned. .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? .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 Singularity Data Lake Console')] [ValidateScript({ $TypeName = $_ | Get-Member | Select-Object -ExpandProperty TypeName -Unique if ($TypeName -eq 'System.String' -or $TypeName -eq 'System.UriBuilder') { [System.UriBuilder]$_ } })] [System.UriBuilder] $SdlConsoleUrl, [Parameter(Mandatory=$false, HelpMessage = 'SentinelOne Console Scope (<account scope ID>:<site scope ID> or <account scope ID>)')] [string] $Scope, [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 ) 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('SdlConsoleUrl')) -and ($PSBoundParameters.ContainsKey('Credential'))) { $config = [pscustomobject]@{ sdlConsoleUrl = $null credential = $null } } else { $config = Get-SdlConfig } # Use parameters if set if ($PSBoundParameters.ContainsKey('SdlConsoleUrl')) { $config.sdlConsoleUrl = $SdlConsoleUrl.Host } if ($PSBoundParameters.ContainsKey('Credential')) { $config.credential = $Credential } Write-Debug ("Config compiled: {0}" -f $config) # Build URI $Uri = [System.UriBuilder]("https://{0}{1}" -f $config.sdlConsoleUrl, $ApiEndpoint) Write-Verbose ('{0}: Calling URL: {1}' -f $Me, $Uri) if (($Method -eq 'GET') -and $Body) { throw "Cannot specify Request Body for Method GET." } $Header = @{} $Header.Add('Content-Type', 'application/json') # A Scope has been specified, add the S1-Scope header if ($PSBoundParameters.ContainsKey('Scope')) { Write-Verbose ('{0} : Scope supplied') $Header.Add('S1-Scope', $Scope) } # Buld Rest Method parameters $params = @{ Authentication = 'Bearer' Token = $Credential.Password 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) } } 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 ('{0} : Exception : {1}' -f $Me, $($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.status -Contains '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 } } $Results,$ResponseHeaders = Invoke-Command -ScriptBlock $apiCallCode Invoke-Command -ScriptBlock $processResults } End { Write-Verbose "Clearing variables" $Credential = $null Remove-Variable -Name config Remove-Variable -Name Credential Remove-Variable -Name Results Remove-Variable -Name ErrorObject Remove-Variable -Name ResponseHeaders Remove-Variable -Name apiCallCode Remove-Variable -Name processResults return } } 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 base URL for the customers Singularity Data Lake Console')] [System.UriBuilder] $SdlConsoleUrl, [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} SdlConsoleUrl: {1} ApiVersion: {2} Credential: {3}" -f $ManagementUrl, $SdlConsoleUrl, $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 SdlConsoleUrl = $SdlConsoleUrl.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 Invoke-SdlPowerQuery { <# .DESCRIPTION Execute a Singularity Datalake PowerQuery and return the results. Results are returned as an array of Hash/PSObject. .SYNOPSIS Execute a Singularity Datalake PowerQuery and return the results. .PARAMETER SdlConsoleUri Base API URL for the API Call .PARAMETER Credential PSCredential Object with the API Key stored in the Password property of the object. The Credential can be a SentinelOne API Key for a user or Service Account, or an SDL API Token. When using a SentinelOne API Key, where that key has access to multiple Accounts and/or Sites, you must also specify a Scope. When using an SDL API Token, the token must have Read Permission. .PARAMETER Scope S1 Scope specifier. If the supplied credential is a SentinelOne API token with access to multiple accounts or sites, this must be supplied. Without this no data is returned. .PARAMETER Query Filters to apply to the query in hashtable format. eg. $filters = @{ groupIds = @(225494730938493804, 225494730938493915) sortBy = 'id' computerName__like = 'azm' } .OUTPUTS Returns a PSCustomObject containing the sites and its data Returns ErrorObject if an error is encountered .EXAMPLE PS> Invoke-SdlQuery -Credential (Get-Credential) -SdlConsoleUrl 'https://xdr.us1.sentinelone.net' .EXAMPLE PS> Invoke-SdlQuery -Credential (Get-Credential) -Scope "<accountId>:<siteId>" -SdlConsoleUrl 'https://xdr.us1.sentinelone.net' #> [CmdletBinding(DefaultParameterSetName = 'All')] param( [Parameter(Mandatory = $false, HelpMessage = 'The base URL for the customers Singularity Data Lake Console. Eg. https://xdr.us1.sentinelone.net')] [ValidateScript({ $TypeName = $_ | Get-Member | Select-Object -ExpandProperty TypeName -Unique if ($TypeName -eq 'System.String' -or $TypeName -eq 'System.UriBuilder') { [System.UriBuilder]$_ } })] [System.UriBuilder] $SdlConsoleUrl, [Parameter(Mandatory = $false, HelpMessage = 'SentinelOne Console Scope (<account scope ID>:<site scope ID> or <account scope ID>)')] [string] $Scope, [Parameter(Mandatory = $false, HelpMessage = 'PSCredential Object containing the API Key in the Password property')] [PSCredential] $Credential, [Parameter(Mandatory = $false, HelpMessage = 'Optional. Omit StartTime and EndTime to search the past 24 hours')] [string] $StartTime, [Parameter(Mandatory = $false, HelpMessage = 'Optional. Omit StartTime and EndTime to search the past 24 hours')] [string] $EndTime, [Parameter(Mandatory = $false, HelpMessage = 'Optional (defaults to low). The execution priority for the query. Set to "low" when a delay of approximately a second is acceptable, and "high" if not. Low-priority queries have more generous rate-limits.')] [ValidateSet('low','high')] [string] $Priority = 'low', [Parameter(Mandatory = $true, HelpMessage = 'The query, in PowerQuery syntax. There is a 10000 (10k) character limit for query.')] [string] $Query ) Begin { $ApiEndpoint = [System.Text.StringBuilder]'/api/powerQuery' $Method = 'POST' # Build up parameter splat to pass on to Invoke-SdlApiRequest $functionParameters = @{} $functionParameters.Add('Method', $Method) $functionParameters.Add('SdlConsoleUrl', $SdlConsoleUrl) $functionParameters.Add('ApiEndpoint', $ApiEndpoint) if ($Credential) { $functionParameters.Add('Credential', $Credential) } if ($Scope) { $functionParameters.Add('Scope', $Scope) } } Process { # Build up the request body $body = @{} $body.query = $Query $body.priority = $Priority if ($StartTime) { # May have a start time. If not supplied SDL will automatically do the last 24h $body.startTime = $StartTime } if ($EndTime) { # May have and end time. If not supplied, SDL will automatically set it to 24h after start time. $body.endtime = $EndTime } $functionParameters.Body = $body $Result = Invoke-SdlApiRequest @functionParameters #if ($Result.status -ne 'success') { # throw ("{0}: API Response status is {1}: {2}" -f $Me, $Result.status, $Result.message) #} # Results are returned in two parts: # - An array of columns, providing the column/field names # - An array of rows, each being an array of fields/columns that match the previously provided column names # # Transform this to make it more useful and return it return (Convert-SdlPowerQueryResponseToHash -PqResponse $Result) } End { Remove-Variable Result } } # Function that returns a PSCustomObject containing SentinelOne site data function Invoke-SdlQuery { <# .DESCRIPTION Execute a Singularity Datalake Query and return the results. .SYNOPSIS Execute a Singularity Datalake Query and return the results. The results are returned as an array of hashes/psobject. .PARAMETER SdlConsoleUri Base API URL for the API Call .PARAMETER Credential PSCredential Object with the API Key stored in the Password property of the object. The Credential can be a SentinelOne API Key for a user or Service Account, or an SDL API Token. When using a SentinelOne API Key, where that key has access to multiple Accounts and/or Sites, you must also specify a Scope. When using an SDL API Token, the token must have Read Permission. .PARAMETER Scope S1 Scope specifier. If the supplied credential is a SentinelOne API token with access to multiple accounts or sites, this must be supplied. Without this no data is returned. .PARAMETER Query Filters to apply to the query in hashtable format. eg. $filters = @{ groupIds = @(225494730938493804, 225494730938493915) sortBy = 'id' computerName__like = 'azm' } .OUTPUTS Returns a PSCustomObject containing the query results Returns ErrorObject if an error is encountered .EXAMPLE PS> Invoke-SdlQuery -Credential (Get-Credential) -SdlConsoleUrl 'https://xdr.us1.sentinelone.net' .EXAMPLE PS> Invoke-SdlQuery -Credential (Get-Credential) -Scope "<accountId>:<siteId>" -SdlConsoleUrl 'https://xdr.us1.sentinelone.net' #> [CmdletBinding(DefaultParameterSetName = 'Start')] param( [Parameter(Mandatory = $false, HelpMessage = 'The base URL for the customers Singularity Data Lake Console')] [ValidateScript({ $TypeName = $_ | Get-Member | Select-Object -ExpandProperty TypeName -Unique if ($TypeName -eq 'System.String' -or $TypeName -eq 'System.UriBuilder') { [System.UriBuilder]$_ } })] [System.UriBuilder] $SdlConsoleUrl, [Parameter(Mandatory = $false, HelpMessage = 'SentinelOne Console Scope (<account scope ID>:<site scope ID> or <account scope ID>)')] [string] $Scope, [Parameter(Mandatory = $false, HelpMessage = 'PSCredential Object containing the API Key in the Password property')] [PSCredential] $Credential, [Parameter(Mandatory = $false, HelpMessage = 'Optional. Omit StartTime and EndTime to search the past 24 hours')] [string] $StartTime, [Parameter(Mandatory = $false, HelpMessage = 'Optional. Omit StartTime and EndTime to search the past 24 hours')] [string] $EndTime, [Parameter(Mandatory = $false, HelpMessage = 'Optional (defaults to low). The execution priority for the query. Set to "low" when a delay of approximately a second is acceptable, and "high" if not. Low-priority queries have more generous rate-limits.')] [ValidateSet('low','high')] [string] $Priority = 'low', [Parameter(Mandatory = $true, HelpMessage = 'The query, in PowerQuery syntax. There is a 10000 (10k) character limit for query.')] [string] $Query, [Parameter(Mandatory = $false, HelpMessage = 'Optional (defaults to "", all fields). An array of fields to get for each log message. For example ("severity", "serverHost").')] [string[]] $Columns, [Parameter(Mandatory = $false, HelpMessage = 'Optional (defaults to 100). The maximum number of events to get. Set from 1 to 5000.')] [Int32] $MaxCount = 100, [Parameter(Mandatory = $false, HelpMessage = 'Used to "page through" query results when maxCount is exceeded. Do not set for your first query.')] [string] $ContinuationToken, [Parameter(Mandatory = $false, HelpMessage = 'Optional (defaults to head when StartTime is set, otherwise to tail). When the umber of matching records exceeds MaxCount, set to "head" to get the oldest "MaxCount" events, and "Tail" to get the newest "MaxCount" events.')] [ValidateSet('head','tail')] [string] $PageMode, [Parameter(Mandatory = $false, HelpMessage = 'Only return the first page, even if additional results are available.')] [switch] $PageOnly ) Begin { $ApiEndpoint = [System.Text.StringBuilder]'/api/query' $Method = 'POST' # Build up parameter splat to pass on to Invoke-SdlApiRequest $functionParameters = @{} $functionParameters.Add('Method', $Method) $functionParameters.Add('SdlConsoleUrl', $SdlConsoleUrl) $functionParameters.Add('ApiEndpoint', $ApiEndpoint) if ($Credential) { $functionParameters.Add('Credential', $Credential) } if ($Scope) { $functionParameters.Add('Scope', $Scope) } if (!$PSBoundParameters.ContainsKey('PageMode')) { if ($PSBoundParameters.ContainsKey('StartTime')) { $PageMode = 'head' } else { $PageMode = 'tail' } } } Process { # Build up the request body $body = @{} $body.filter = $Query $body.queryType = 'log' $body.priority = $Priority if ($StartTime) { # May have a start time. If not supplied SDL will automatically do the last 24h $body.startTime = $StartTime } if ($EndTime) { # May have and end time. If not supplied, SDL will automatically set it to 24h after start time. $body.endtime = $EndTime } $body.maxCount = $MaxCount $body.pageMode = $PageMode if ($PSBoundParameters.ContainsKey('ContinuationToken')) { $body.continuationToken = $ContinuationToken } if ($PSBoundParameters.ContainsKey('Columns')) { $body.columns = $Columns -join ',' } $functionParameters.Body = $body $Result = Invoke-SdlApiRequest @functionParameters if ($Result.status -ne 'success') { throw ("{0}: API Response status is {1}: {2}" -f $Me, $Result.status, $Result.message) } Write-Output $Result if (($Result.matches.count -eq $MaxCount) -and ($Result.continuationToken) -and !($PSBoundParameters.ContainsKey('PageOnly'))) { # Additional page is available, and we have not been told to only return one page $recurseParameters = @{} $recurseParameters.SdlConsoleUrl = $SdlConsoleUrl $recurseParameters.Credential = $Credential if ($PSBoundParameters.ContainsKey('Scope')) { $recurseParameters.Scope = $Scope } $recurseParameters.Query = $Query $recurseParameters.Priority = $Priority if ($PSBoundParameters.ContainsKey('StartTime')) { $recurseParameters.StartTime = $StartTime } if ($PSBoundParameters.ContainsKey('StartTime')) { $recurseParameters.EndTime = $EndTime } if ($PSBoundParameters.ContainsKey('Columns')) { $recurseParameters.Columns = $Columns } $recurseParameters.MaxCount = $MaxCount $recurseParameters.PageMode = $PageMode $recurseParameters.continuationToken = $Result.continuationToken Write-Output (Invoke-SdlQuery @recurseParameters) } } End { Remove-Variable Result } } # 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 MyAccountID Account ID .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('accountId')] [Parameter(Mandatory=$false, HelpMessage = 'ID of the account', ValueFromPipelineByPropertyName = $true)] [string] $ByAccountID, [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() $functionParameters = @{} if ($ManagementUrl) { $functionParameters.ManagementUrl = $ManagementUrl } if ($Credential) { $functionParameters.Credential = $Credential } $functionParameters.ApiVersion = $ApiVersion } Process { # Filter options if ($AccountId) { $ApiEndpoint = [pscustomobject]@{accountId = $AccountId} | Get-S1Filter -ApiEndpoint $ApiEndpoint } 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 Write-Verbose ('{0}: ApiEndpoint: {1}' -f $MyInvocation.MyCommand.Name, $ApiEndpoint) # 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 MyAccountID Account ID .PARAMETER BySiteID Site ID .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('accountId')] [Parameter(Mandatory = $false, HelpMessage = 'Site ID of the agents the threats belong to', #ParameterSetName = 'BySiteID', ValueFromPipelineByPropertyName = $true)] [string]$ByAccountID, [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 ($ByAccountId) { $ApiEndpoint = [pscustomobject]@{accountId = $ByAccountId } | Get-S1Filter -ApiEndpoint $ApiEndpoint } if ($BySiteID) { $ApiEndpoint = [pscustomobject]@{siteId = $BySiteID } | Get-S1Filter -ApiEndpoint $ApiEndpoint } 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 } Write-Verbose ('{0}: ApiEndpoint: {1}' -f $MyInvocation.MyCommand.Name, $ApiEndpoint) # 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 MyAccountID Account ID .PARAMETER BySiteID Site ID .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('accountId')] [Parameter(Mandatory = $false, HelpMessage = 'Site ID of the agents the threats belong to', #ParameterSetName = 'BySiteID', ValueFromPipelineByPropertyName = $true)] [string]$ByAccountID, [Alias('siteId')] [Parameter(Mandatory = $false, HelpMessage = 'Site ID of the agents the threats belong to', #ParameterSetName = 'BySiteID', ValueFromPipelineByPropertyName = $true)] [string]$BySiteID, [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 { # Filter by Account and/or Site if specified if ($ByAccountId) { $ApiEndpoint = [pscustomobject]@{accountId = $ByAccountId } | Get-S1Filter -ApiEndpoint $ApiEndpoint } if ($BySiteID) { $ApiEndpoint = [pscustomobject]@{siteId = $BySiteID } | Get-S1Filter -ApiEndpoint $ApiEndpoint } 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 } Write-Verbose ('{0}: ApiEndpoint: {1}' -f $MyInvocation.MyCommand.Name, $ApiEndpoint) # 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 base URL for the customers Singularity Data Lake Console')] [System.UriBuilder] $SdlConsoleUrl, [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} SdlConsoleUrl: {1} ApiVersion: {2} Credential: {3}" -f $ManagementUrl, $SdlConsoleUrl, $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 SdlConsoleUrl = $SdlConsoleUrl.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-S1Account, Get-S1Activity, Get-S1ActivityType, Get-S1Agent, Get-S1AgentCount, Invoke-SdlApiRequest, Invoke-SentinelOneApiRequest, Get-S1Group, New-SentinelOneConfig, Invoke-SdlPowerQuery, Invoke-SdlQuery, Get-S1Site, Get-S1Threat, Get-S1User, New-SentinelOneConfig # SIG # Begin signature block # MIIt4wYJKoZIhvcNAQcCoIIt1DCCLdACAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCC7Pincz3B3CSAF # Ou2nxfpXf9buF2hCbBpyL9AVr65ifaCCEyswggWQMIIDeKADAgECAhAFmxtXno4h # MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV # BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z # ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB # AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z # G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ # anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s # Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL # 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb # BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3 # JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c # AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx # YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0 # viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL # T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud # EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf # Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk # aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS # PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK # 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB # cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp # 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg # dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri # RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7 # 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5 # nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3 # i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H # EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G # CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ # bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0 # IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C # 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce # 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da # E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T # SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA # FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh # D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM # 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z # 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05 # huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY # mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP # /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T # AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD # VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG # A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV # HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU # cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN # BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry # sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL # IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf # Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh # OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh # dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV # 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j # wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH # Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC # XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l # /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW # eE4wggbfMIIEx6ADAgECAhADOvN90luA0nrA93Kao14iMA0GCSqGSIb3DQEBCwUA # MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE # AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz # ODQgMjAyMSBDQTEwHhcNMjQxMjE5MDAwMDAwWhcNMjYwMTE2MjM1OTU5WjBnMQsw # CQYDVQQGEwJBVTERMA8GA1UECBMIVmljdG9yaWExFTATBgNVBAcTDE5vdHRpbmcg # SGlsbDEWMBQGA1UEChMNSVBTZWMgUHR5IEx0ZDEWMBQGA1UEAxMNSVBTZWMgUHR5 # IEx0ZDCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKAKgpRe3of6uvFi # CACixkD18+fCx2CROSBlMrWeSwogIGRx2Gm/BEZhUzGLPVAXN33pPlYo95K2aben # DHHuN6JV/fEjEphS9c3bm+QgP1q96uceh+ytVY0oq3uONcDdYIOR/kdCkH07lPo3 # jPEEkFfyKsNFH+DpaFwLqq8fmzj9euT5JSGRlZi5mC0qquZ0hEVNkFQ3vhV6vW4a # AQ4YlkgYs4Fl7DHZKSnwwHTYCSjmZzJVaX3GcR2yZv5vrkEWGfP0IcXZAPwnHDjd # jSWfTSmfMLu0Yh6rWmtm1Hw/2/3CWmkMGv0yYFxevuso3FkuuesnqS/sNxJP29BR # DauxouAVbp8ipOg1oaQvpkWsFMzb36DCdKHWWSmIbCp5niUwMiLsg/8mYYRf+T1I # JsAPZYXY5taIQssBBmlUJgiIxACEw52mc6fjy+35wK69X12OTnYRfZQ2ORa/JUIX # SZ2VkQZ4qDLzJyZWWUwXipPyLHhXMJw7oxnNU4ocFwcxL9/SQwIDAQABo4ICAzCC # Af8wHwYDVR0jBBgwFoAUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHQYDVR0OBBYEFBT8 # Z08ugGGhp3FyFX/0iw9X9KENMD4GA1UdIAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYB # BQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNlcnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMC # B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwgbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0 # cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25p # bmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNI # QTM4NDIwMjFDQTEuY3JsMIGUBggrBgEFBQcBAQSBhzCBhDAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMFwGCCsGAQUFBzAChlBodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JT # QTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJBgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUA # A4ICAQC6aprHmEJvSXJ+yM3xzoN1W8to3bZBh2MtR6+UIuXC/q9lxCrW6ZlJTmRc # wodjaP6aX4r4oaWwbe7DuYkCSDylP995O+WA46Tu3KqmGBrPQQWzlfLx0Uv3dMvN # Da2I314DtOwK4Ynrl2MBgfJdxns8YK1fdk2vfVm5dMG63GSzh+Prgwt20K4AyI4C # GCljQy61sud7pVcNlJs0jjDq7yRT4IOu9jFazx7nqrhBbyllcx/qog5w+u2W58tx # Se/SVHbTcizoXxyluCCPfspQ+EYTVCC5BTSHpF7iRxHmthmUEamUI4/vh7cwmQG6 # Rtq0wNNYnLq29JpcvrxV4GDmtZGb2Iok0+/YZUoTuTp5zk3RZ034FAszBbTQ+vbn # 87xa+Pckkx5PZYJsx8KCSZ1TJtPZSegy7pMMWYNfcHd5s9NNt/aOFdCqjuVSKJGH # zaIY09oS0ClUUCHNQaVQJAF0daLWwo3WmOywaziXTmYR+5rSfNl9aGzzrW+ardL8 # IIzLURVE/VHJ+NHCdB+kiuWaE0ivHKjBxIvpKUNeoQTeSTzZ/aRbhAx1hDANa4MB # Z1jdB4/jWsGbVE8aY4WSdmw3BOEGhm2mc3S6QVdMF8Be98V+JEqJXmg1nD9ySok4 # 55d3duhIe3KsYB/lJx9uLzn+e0bwXHDu63AAMi6pkLB1x3/hSDGCGg4wghoKAgEB # MH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYD # VQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IENvZGUgU2lnbmluZyBSU0E0MDk2IFNI # QTM4NCAyMDIxIENBMQIQAzrzfdJbgNJ6wPdymqNeIjANBglghkgBZQMEAgEFAKCB # hDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEE # AYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJ # BDEiBCBCeazjv4cvcpB4bB/l1QCrc2o3XPBkG30PC1EYluur3jANBgkqhkiG9w0B # AQEFAASCAYAO+7UevT7JE+0BKCJd2JANdlrGSvn0fyXMGd+iloDk1ovzYV7kkNo0 # xBvoOiyCmMABXalQB/vtQbP8fR5aorvi/rn/uWWG63lixHn22h/PMdAwQocpbk8r # E9GjzTWPuRXBvCu9sGFcly0qajjGGw65pDkjsWJxVVs2DEzvkuQ07+WxvBomiHGx # H6PzBp4Y6/LMlOC4PHBZIjuKi8kRW7+1a66Xv6b7swa9vc3nAh/qOjAwMzgf2fwq # Z1ZOj5M+ybTqgWW/z/hSVfjslWRzDU9SRB4F0WH4t3HWXvMbkl6177F0uCBtlNlb # ymRoKFBYSl8P7MhiOIyVxvBfF17zgsakNNAKeUKLlFCzEpX4VocFv/iWEgO+/E5r # //NcERvdL3XHbi1ARGO66HTizW2EVM9RkiVQKYth7Dio+6v8lYfo5uQRmtS8CiBc # sEuyY10lL6V3yIftdVKbDlOVuDneY3AVzITfoIDf1sAhAAJysbsLnRrWuYABtvTP # 9uXmy3OVvHShghdbMIIXVwYKKwYBBAGCNwMDATGCF0cwghdDBgkqhkiG9w0BBwKg # ghc0MIIXMAIBAzEPMA0GCWCGSAFlAwQCAgUAMIGIBgsqhkiG9w0BCRABBKB5BHcw # dQIBAQYJYIZIAYb9bAcBMEEwDQYJYIZIAWUDBAICBQAEMDPcu9eoKsnzsO1Xj9Jo # IUA/t9F+qj9oheLnITONdNjJkQguB3ZjIm5hdpi6FiKdtgIRAKwovETMv7uOyXqx # FyjZqMYYDzIwMjUwNjAzMDIzMzA1WqCCEwMwgga8MIIEpKADAgECAhALrma8Wrp/ # lYfG+ekE4zMEMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQK # Ew5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBS # U0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjQwOTI2MDAwMDAwWhcN # MzUxMTI1MjM1OTU5WjBCMQswCQYDVQQGEwJVUzERMA8GA1UEChMIRGlnaUNlcnQx # IDAeBgNVBAMTF0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDI0MIICIjANBgkqhkiG9w0B # AQEFAAOCAg8AMIICCgKCAgEAvmpzn/aVIauWMLpbbeZZo7Xo/ZEfGMSIO2qZ46XB # /QowIEMSvgjEdEZ3v4vrrTHleW1JWGErrjOL0J4L0HqVR1czSzvUQ5xF7z4IQmn7 # dHY7yijvoQ7ujm0u6yXF2v1CrzZopykD07/9fpAT4BxpT9vJoJqAsP8YuhRvflJ9 # YeHjes4fduksTHulntq9WelRWY++TFPxzZrbILRYynyEy7rS1lHQKFpXvo2GePfs # MRhNf1F41nyEg5h7iOXv+vjX0K8RhUisfqw3TTLHj1uhS66YX2LZPxS4oaf33rp9 # HlfqSBePejlYeEdU740GKQM7SaVSH3TbBL8R6HwX9QVpGnXPlKdE4fBIn5BBFnV+ # KwPxRNUNK6lYk2y1WSKour4hJN0SMkoaNV8hyyADiX1xuTxKaXN12HgR+8WulU2d # 6zhzXomJ2PleI9V2yfmfXSPGYanGgxzqI+ShoOGLomMd3mJt92nm7Mheng/TBeSA # 2z4I78JpwGpTRHiT7yHqBiV2ngUIyCtd0pZ8zg3S7bk4QC4RrcnKJ3FbjyPAGogm # oiZ33c1HG93Vp6lJ415ERcC7bFQMRbxqrMVANiav1k425zYyFMyLNyE1QulQSgDp # W9rtvVcIH7WvG9sqYup9j8z9J1XqbBZPJ5XLln8mS8wWmdDLnBHXgYly/p1DhoQo # 5fkCAwEAAaOCAYswggGHMA4GA1UdDwEB/wQEAwIHgDAMBgNVHRMBAf8EAjAAMBYG # A1UdJQEB/wQMMAoGCCsGAQUFBwMIMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsGCWCG # SAGG/WwHATAfBgNVHSMEGDAWgBS6FtltTYUvcyl2mi91jGogj57IbzAdBgNVHQ4E # FgQUn1csA3cOKBWQZqVjXu5Pkh92oFswWgYDVR0fBFMwUTBPoE2gS4ZJaHR0cDov # L2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNIQTI1 # NlRpbWVTdGFtcGluZ0NBLmNybDCBkAYIKwYBBQUHAQEEgYMwgYAwJAYIKwYBBQUH # MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBYBggrBgEFBQcwAoZMaHR0cDov # L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0UlNBNDA5NlNI # QTI1NlRpbWVTdGFtcGluZ0NBLmNydDANBgkqhkiG9w0BAQsFAAOCAgEAPa0eH3aZ # W+M4hBJH2UOR9hHbm04IHdEoT8/T3HuBSyZeq3jSi5GXeWP7xCKhVireKCnCs+8G # Zl2uVYFvQe+pPTScVJeCZSsMo1JCoZN2mMew/L4tpqVNbSpWO9QGFwfMEy60HofN # 6V51sMLMXNTLfhVqs+e8haupWiArSozyAmGH/6oMQAh078qRh6wvJNU6gnh5OruC # P1QUAvVSu4kqVOcJVozZR5RRb/zPd++PGE3qF1P3xWvYViUJLsxtvge/mzA75oBf # FZSbdakHJe2BVDGIGVNVjOp8sNt70+kEoMF+T6tptMUNlehSR7vM+C13v9+9ZOUK # zfRUAYSyyEmYtsnpltD/GWX8eM70ls1V6QG/ZOB6b6Yum1HvIiulqJ1Elesj5TMH # q8CWT/xrW7twipXTJ5/i5pkU5E16RSBAdOp12aw8IQhhA/vEbFkEiF2abhuFixUD # obZaA0VhqAsMHOmaT3XThZDNi5U2zHKhUs5uHHdG6BoQau75KiNbh0c+hatSF+02 # kULkftARjsyEpHKsF7u5zKRbt5oK5YGwFvgc4pEVUNytmB3BpIiowOIIuDgP5M9W # ArHYSAR16gc0dP2XdkMEP5eBsX7bf/MGN4K3HP50v/01ZHo/Z5lGLvNwQ7XHBx1y # omzLP8lx4Q1zZKDyHcp4VQJLu2kWTsKsOqQwggauMIIElqADAgECAhAHNje3JFR8 # 2Ees/ShmKl5bMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV # BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0yMjAzMjMwMDAwMDBaFw0z # NzAzMjIyMzU5NTlaMGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwg # SW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBHNCBSU0E0MDk2IFNIQTI1 # NiBUaW1lU3RhbXBpbmcgQ0EwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC # AQDGhjUGSbPBPXJJUVXHJQPE8pE3qZdRodbSg9GeTKJtoLDMg/la9hGhRBVCX6SI # 82j6ffOciQt/nR+eDzMfUBMLJnOWbfhXqAJ9/UO0hNoR8XOxs+4rgISKIhjf69o9 # xBd/qxkrPkLcZ47qUT3w1lbU5ygt69OxtXXnHwZljZQp09nsad/ZkIdGAHvbREGJ # 3HxqV3rwN3mfXazL6IRktFLydkf3YYMZ3V+0VAshaG43IbtArF+y3kp9zvU5Emfv # DqVjbOSmxR3NNg1c1eYbqMFkdECnwHLFuk4fsbVYTXn+149zk6wsOeKlSNbwsDET # qVcplicu9Yemj052FVUmcJgmf6AaRyBD40NjgHt1biclkJg6OBGz9vae5jtb7IHe # IhTZgirHkr+g3uM+onP65x9abJTyUpURK1h0QCirc0PO30qhHGs4xSnzyqqWc0Jo # n7ZGs506o9UD4L/wojzKQtwYSH8UNM/STKvvmz3+DrhkKvp1KCRB7UK/BZxmSVJQ # 9FHzNklNiyDSLFc1eSuo80VgvCONWPfcYd6T/jnA+bIwpUzX6ZhKWD7TA4j+s4/T # Xkt2ElGTyYwMO1uKIqjBJgj5FBASA31fI7tk42PgpuE+9sJ0sj8eCXbsq11GdeJg # o1gJASgADoRU7s7pXcheMBK9Rp6103a50g5rmQzSM7TNsQIDAQABo4IBXTCCAVkw # EgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUuhbZbU2FL3MpdpovdYxqII+e # yG8wHwYDVR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQD # AgGGMBMGA1UdJQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEF # BQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRw # Oi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNy # dDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGln # aUNlcnRUcnVzdGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglg # hkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAH1ZjsCTtm+YqUQiAX5m1tghQuGw # GC4QTRPPMFPOvxj7x1Bd4ksp+3CKDaopafxpwc8dB+k+YMjYC+VcW9dth/qEICU0 # MWfNthKWb8RQTGIdDAiCqBa9qVbPFXONASIlzpVpP0d3+3J0FNf/q0+KLHqrhc1D # X+1gtqpPkWaeLJ7giqzl/Yy8ZCaHbJK9nXzQcAp876i8dU+6WvepELJd6f8oVInw # 1YpxdmXazPByoyP6wCeCRK6ZJxurJB4mwbfeKuv2nrF5mYGjVoarCkXJ38SNoOeY # +/umnXKvxMfBwWpx2cYTgAnEtp/Nh4cku0+jSbl3ZpHxcpzpSwJSpzd+k1OsOx0I # SQ+UzTl63f8lY5knLD0/a6fxZsNBzU+2QJshIUDQtxMkzdwdeDrknq3lNHGS1yZr # 5Dhzq6YBT70/O3itTK37xJV77QpfMzmHQXh6OOmc4d0j/R0o08f56PGYX/sr2H7y # Rp11LB4nLCbbbxV7HhmLNriT1ObyF5lZynDwN7+YAN8gFk8n+2BnFqFmut1VwDop # hrCYoCvtlUG3OtUVmDG0YgkPCr2B2RP+v6TR81fZvAT6gt4y3wSJ8ADNXcL50CN/ # AAvkdgIm2fBldkKmKYcJRyvmfxqkhQ/8mJb2VVQrH4D6wPIOK+XW+6kvRBVK5xMO # Hds3OBqhK/bt1nz8MIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkq # hkiG9w0BAQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5j # MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBB # c3N1cmVkIElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5 # WjBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQL # ExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJv # b3QgRzQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1K # PDAiMGkz7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2r # snnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C # 8weE5nQ7bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBf # sXpm7nfISKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGY # QJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8 # rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaY # dj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+ # wJS00mFt6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw # ++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+N # P8m800ERElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7F # wI+isX4KJpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUw # AwEB/zAdBgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAU # Reuir/SSy4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEB # BG0wazAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsG # AQUFBzAChjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1 # cmVkSURSb290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRp # Z2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAow # CDAGBgRVHSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/ # Vwe9mqyhhyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLe # JLxSA8hO0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE # 1Od/6Fmo8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9Hda # XFSMb++hUD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbO # byMt9H5xaiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMYID # hjCCA4ICAQEwdzBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu # Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFRydXN0ZWQgRzQgUlNBNDA5NiBTSEEyNTYg # VGltZVN0YW1waW5nIENBAhALrma8Wrp/lYfG+ekE4zMEMA0GCWCGSAFlAwQCAgUA # oIHhMBoGCSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAcBgkqhkiG9w0BCQUxDxcN # MjUwNjAzMDIzMzA1WjArBgsqhkiG9w0BCRACDDEcMBowGDAWBBTb04XuYtvSPnvk # 9nFIUIck1YZbRTA3BgsqhkiG9w0BCRACLzEoMCYwJDAiBCB2dp+o8mMvH0MLOiMw # rtZWdf7Xc9sF1mW5BZOYQ4+a2zA/BgkqhkiG9w0BCQQxMgQwFtmcK8Bi2flI62MV # kPTdApdwkzYEJnhaN1ep20Mlhn2HB6sA4EMMBwP2moAfiX9bMA0GCSqGSIb3DQEB # AQUABIICAGher9eZSmja8yu/j7+a+nfJMXZsEG17fVww6IxteoCE6uJRlUXbcHUm # rBLf4IjlTHFb7p0rOr8vFajh7pPlskMitCu43eCaFMuv9sQhGbP0AvKSuVnY6Npv # 1CpU6tysKYWGOQ/y74cpff90CaK73B+0nItZGPrpDiQrXim0iHYHZaeCa0tZOcw3 # N8cSZWz4N/BGeuHjCWYkdMWAdjLo4nieXI4YcXnpwKdr1ylkXEWP8ftCebFYl+fr # XOOrgVo2Iw0g0EcyvUPyVrjjwtJyQO4Jz/whwqaHxaPqaMa0xCp8gdTu1M+Ay6pF # t3n3HEOB9xAL7tRbqHe4kNQuRSzIiEOHeZ1l509XgF0yttRml3Ke58Sa425CDJR/ # e9eOoaK1GOCmda3nbN8RX5ft/z76yT5TUU29aQLlTR6q4OFGw8YdfyE/6dZJU+2i # XTXbRHqGUontifbsunnsrm894kREpc8IUnhDAFXFVrCJJQjP+xXxlO+aBSY4yzqn # NYA0jDZPNfaz4F6ElfYln7Zs9eu6pFPoCJt490jxRF/B3/42Udbu2QhYGthODLK9 # 5PZbPYvKZwpupHLhQtcZ2I5q0VYA9/2ADHrj7h2YU7JNZNcPLsrfHOylFgTZym9L # S0m0CVDyG37drtd3fWSnKlA8JvS/6KVA+QN/rcVytKfh4x8C+c9n # SIG # End signature block |