SCENARIO
This document provides a comprehensive library of production-ready PowerShell scripts for automating SentinelOne operations in an MSP environment. These scripts leverage the SentinelOne REST API to automate agent management, threat response, reporting, and multi-tenant operations.
Use these scripts when:
- Automating daily operational tasks across multiple client sites
- Building scheduled threat hunting and reporting workflows
- Managing agent deployments and health at scale
- Integrating SentinelOne with PSA/RMM platforms
- Generating compliance and executive reports
- Performing bulk operations across the MSP tenant
Prerequisites:
- SentinelOne Singularity Complete license
- API token with appropriate permissions
- PowerShell 5.1+ (Windows) or PowerShell 7+ (cross-platform)
- Network connectivity to SentinelOne console (HTTPS/443)
1. API AUTHENTICATION MODULE
SentinelOne-API-Module.psm1
This core module provides secure authentication, rate limiting, and error handling for all API operations.
#Requires -Version 5.1
<#
.SYNOPSIS
SentinelOne API Authentication and Request Module
.DESCRIPTION
Provides secure credential storage, token management, rate limiting,
and error handling wrapper functions for SentinelOne API operations.
.NOTES
Version: 2.0
Author: CosmicBytez
Requires: PowerShell 5.1+
#>
# Module-scoped variables
$script:S1Config = @{
ApiToken = $null
ConsoleUrl = $null
RateLimitRemaining = 1000
RateLimitReset = $null
LastRequestTime = $null
MinRequestInterval = 100 # milliseconds between requests
}
$script:LogPath = "C:\BIN\LOGS"
#region Credential Management
function Set-S1CredentialStore {
<#
.SYNOPSIS
Securely stores SentinelOne API credentials using Windows DPAPI
.PARAMETER ConsoleUrl
SentinelOne console URL (e.g., https://usea1-partners.sentinelone.net)
.PARAMETER ApiToken
API token (will prompt securely if not provided)
.PARAMETER CredentialPath
Path to store encrypted credentials (default: user profile)
.EXAMPLE
Set-S1CredentialStore -ConsoleUrl "https://usea1-partners.sentinelone.net"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidatePattern('^https://.*\.sentinelone\.net$')]
[string]$ConsoleUrl,
[Parameter(Mandatory = $false)]
[string]$ApiToken,
[Parameter(Mandatory = $false)]
[string]$CredentialPath = "$env:USERPROFILE\.sentinelone\credentials.xml"
)
# Create directory if needed
$credDir = Split-Path $CredentialPath -Parent
if (-not (Test-Path $credDir)) {
New-Item -Path $credDir -ItemType Directory -Force | Out-Null
}
# Get token securely if not provided
if (-not $ApiToken) {
$secureToken = Read-Host -Prompt "Enter SentinelOne API Token" -AsSecureString
} else {
$secureToken = ConvertTo-SecureString $ApiToken -AsPlainText -Force
}
# Create credential object
$credential = @{
ConsoleUrl = $ConsoleUrl
ApiToken = $secureToken | ConvertFrom-SecureString
CreatedAt = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
CreatedBy = $env:USERNAME
}
# Export encrypted
$credential | Export-Clixml -Path $CredentialPath -Force
# Set restrictive permissions
$acl = Get-Acl $CredentialPath
$acl.SetAccessRuleProtection($true, $false)
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
$env:USERNAME, "FullControl", "Allow"
)
$acl.SetAccessRule($rule)
Set-Acl -Path $CredentialPath -AclObject $acl
Write-Host "[SUCCESS] Credentials stored securely at: $CredentialPath" -ForegroundColor Green
}
function Get-S1StoredCredential {
<#
.SYNOPSIS
Retrieves stored SentinelOne credentials
.PARAMETER CredentialPath
Path to credential file
.EXAMPLE
$creds = Get-S1StoredCredential
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $false)]
[string]$CredentialPath = "$env:USERPROFILE\.sentinelone\credentials.xml"
)
if (-not (Test-Path $CredentialPath)) {
throw "Credential file not found. Run Set-S1CredentialStore first."
}
$stored = Import-Clixml -Path $CredentialPath
return @{
ConsoleUrl = $stored.ConsoleUrl
ApiToken = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto(
[System.Runtime.InteropServices.Marshal]::SecureStringToBSTR(
($stored.ApiToken | ConvertTo-SecureString)
)
)
}
}
#endregion
#region Connection Management
function Connect-S1Console {
<#
.SYNOPSIS
Establishes connection to SentinelOne API
.PARAMETER ConsoleUrl
SentinelOne console URL
.PARAMETER ApiToken
API token (use stored credentials if not provided)
.PARAMETER UseStoredCredentials
Use credentials from secure store
.EXAMPLE
Connect-S1Console -UseStoredCredentials
.EXAMPLE
Connect-S1Console -ConsoleUrl "https://usea1-partners.sentinelone.net" -ApiToken $token
#>
[CmdletBinding(DefaultParameterSetName = 'Stored')]
param(
[Parameter(ParameterSetName = 'Manual', Mandatory = $true)]
[string]$ConsoleUrl,
[Parameter(ParameterSetName = 'Manual', Mandatory = $true)]
[string]$ApiToken,
[Parameter(ParameterSetName = 'Stored')]
[switch]$UseStoredCredentials
)
if ($UseStoredCredentials -or $PSCmdlet.ParameterSetName -eq 'Stored') {
$creds = Get-S1StoredCredential
$ConsoleUrl = $creds.ConsoleUrl
$ApiToken = $creds.ApiToken
}
$script:S1Config.ConsoleUrl = $ConsoleUrl.TrimEnd('/')
$script:S1Config.ApiToken = $ApiToken
# Validate connection
try {
$test = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/system/info" -Method GET
Write-Host "[SUCCESS] Connected to SentinelOne" -ForegroundColor Green
Write-Host " Console: $($script:S1Config.ConsoleUrl)" -ForegroundColor Cyan
Write-Host " Version: $($test.data.consoleVersion)" -ForegroundColor Cyan
return $true
}
catch {
Write-Error "Failed to connect to SentinelOne: $($_.Exception.Message)"
$script:S1Config.ApiToken = $null
$script:S1Config.ConsoleUrl = $null
return $false
}
}
function Disconnect-S1Console {
<#
.SYNOPSIS
Clears SentinelOne API session
#>
$script:S1Config.ApiToken = $null
$script:S1Config.ConsoleUrl = $null
Write-Host "[INFO] Disconnected from SentinelOne" -ForegroundColor Yellow
}
#endregion
#region API Request Handling
function Invoke-S1ApiRequest {
<#
.SYNOPSIS
Generic wrapper for SentinelOne API requests with rate limiting and error handling
.PARAMETER Endpoint
API endpoint path (e.g., "/web/api/v2.1/agents")
.PARAMETER Method
HTTP method (GET, POST, PUT, DELETE)
.PARAMETER Body
Request body for POST/PUT requests
.PARAMETER QueryParams
Hashtable of query parameters
.PARAMETER MaxRetries
Maximum retry attempts for rate limiting (default: 3)
.EXAMPLE
Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents" -Method GET
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Endpoint,
[Parameter(Mandatory = $false)]
[ValidateSet('GET', 'POST', 'PUT', 'DELETE')]
[string]$Method = 'GET',
[Parameter(Mandatory = $false)]
[hashtable]$Body = @{},
[Parameter(Mandatory = $false)]
[hashtable]$QueryParams = @{},
[Parameter(Mandatory = $false)]
[int]$MaxRetries = 3
)
if (-not $script:S1Config.ApiToken -or -not $script:S1Config.ConsoleUrl) {
throw "Not connected to SentinelOne. Run Connect-S1Console first."
}
# Build query string
if ($QueryParams.Count -gt 0) {
$queryString = ($QueryParams.GetEnumerator() | ForEach-Object {
"$($_.Key)=$([System.Web.HttpUtility]::UrlEncode($_.Value.ToString()))"
}) -join "&"
$Endpoint = "$Endpoint`?$queryString"
}
$uri = "$($script:S1Config.ConsoleUrl)$Endpoint"
$headers = @{
"Authorization" = "ApiToken $($script:S1Config.ApiToken)"
"Content-Type" = "application/json"
"Accept" = "application/json"
}
$requestParams = @{
Uri = $uri
Method = $Method
Headers = $headers
ErrorAction = 'Stop'
UseBasicParsing = $true
}
if ($Method -in @('POST', 'PUT') -and $Body.Count -gt 0) {
$requestParams.Body = ($Body | ConvertTo-Json -Depth 20 -Compress)
}
# Rate limiting - ensure minimum interval between requests
if ($script:S1Config.LastRequestTime) {
$elapsed = (Get-Date) - $script:S1Config.LastRequestTime
if ($elapsed.TotalMilliseconds -lt $script:S1Config.MinRequestInterval) {
Start-Sleep -Milliseconds ($script:S1Config.MinRequestInterval - $elapsed.TotalMilliseconds)
}
}
$retryCount = 0
while ($retryCount -le $MaxRetries) {
try {
$script:S1Config.LastRequestTime = Get-Date
$response = Invoke-RestMethod @requestParams
# Update rate limit tracking from headers (if available)
return $response
}
catch {
$statusCode = $_.Exception.Response.StatusCode.value__
# Handle rate limiting (429)
if ($statusCode -eq 429) {
$retryAfter = 60
if ($_.Exception.Response.Headers['Retry-After']) {
$retryAfter = [int]$_.Exception.Response.Headers['Retry-After']
}
Write-Warning "Rate limited. Waiting $retryAfter seconds..."
Start-Sleep -Seconds ($retryAfter + 1)
$retryCount++
continue
}
# Handle other errors
$errorMessage = $_.Exception.Message
try {
$reader = New-Object System.IO.StreamReader($_.Exception.Response.GetResponseStream())
$responseBody = $reader.ReadToEnd() | ConvertFrom-Json
if ($responseBody.errors) {
$errorMessage = $responseBody.errors[0].detail
}
} catch {}
Write-Error "API Error [$statusCode]: $errorMessage`nEndpoint: $Endpoint"
throw
}
}
throw "Max retries exceeded due to rate limiting"
}
function Get-S1AllPages {
<#
.SYNOPSIS
Retrieves all pages of results using cursor pagination
.PARAMETER Endpoint
API endpoint path
.PARAMETER QueryParams
Initial query parameters
.PARAMETER MaxItems
Maximum items to retrieve (default: unlimited)
.EXAMPLE
$allAgents = Get-S1AllPages -Endpoint "/web/api/v2.1/agents"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Endpoint,
[Parameter(Mandatory = $false)]
[hashtable]$QueryParams = @{},
[Parameter(Mandatory = $false)]
[int]$MaxItems = 0
)
$allData = @()
$cursor = $null
$QueryParams.limit = 1000
do {
if ($cursor) {
$QueryParams.cursor = $cursor
}
$response = Invoke-S1ApiRequest -Endpoint $Endpoint -Method GET -QueryParams $QueryParams
if ($response.data) {
$allData += $response.data
}
$cursor = $response.pagination.nextCursor
Write-Verbose "Retrieved $($allData.Count) items..."
if ($MaxItems -gt 0 -and $allData.Count -ge $MaxItems) {
$allData = $allData | Select-Object -First $MaxItems
break
}
} while ($cursor)
return $allData
}
#endregion
#region Logging Functions
function Write-S1Log {
<#
.SYNOPSIS
Writes log entry to file and console
.PARAMETER Message
Log message
.PARAMETER Level
Log level (INFO, WARN, ERROR, SUCCESS)
.PARAMETER LogFile
Log file name (auto-generated if not specified)
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Message,
[Parameter(Mandatory = $false)]
[ValidateSet('INFO', 'WARN', 'ERROR', 'SUCCESS', 'DEBUG')]
[string]$Level = 'INFO',
[Parameter(Mandatory = $false)]
[string]$LogFile
)
if (-not (Test-Path $script:LogPath)) {
New-Item -Path $script:LogPath -ItemType Directory -Force | Out-Null
}
if (-not $LogFile) {
$LogFile = "SentinelOne-$(Get-Date -Format 'yyyy-MM-dd').log"
}
$logFilePath = Join-Path $script:LogPath $LogFile
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] [$Level] $Message"
Add-Content -Path $logFilePath -Value $logEntry
$color = switch ($Level) {
'INFO' { 'White' }
'WARN' { 'Yellow' }
'ERROR' { 'Red' }
'SUCCESS' { 'Green' }
'DEBUG' { 'Gray' }
}
Write-Host $logEntry -ForegroundColor $color
}
#endregion
# Export module members
Export-ModuleMember -Function @(
'Set-S1CredentialStore',
'Get-S1StoredCredential',
'Connect-S1Console',
'Disconnect-S1Console',
'Invoke-S1ApiRequest',
'Get-S1AllPages',
'Write-S1Log'
)Usage Example:
# First-time setup - store credentials securely
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1"
Set-S1CredentialStore -ConsoleUrl "https://usea1-partners.sentinelone.net"
# In automation scripts - use stored credentials
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1"
Connect-S1Console -UseStoredCredentials
# Make API calls
$agents = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents" -Method GETScheduled Task Integration:
# Create scheduled task for daily automation
$action = New-ScheduledTaskAction -Execute "powershell.exe" `
-Argument "-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\S1-DailyAutomation.ps1"
$trigger = New-ScheduledTaskTrigger -Daily -At "06:00AM"
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount
Register-ScheduledTask -TaskName "SentinelOne-DailyAutomation" `
-Action $action -Trigger $trigger -Principal $principal2. AGENT MANAGEMENT SCRIPTS
Get-S1Agents.ps1 - Agent Inventory with Filtering
#Requires -Version 5.1
<#
.SYNOPSIS
Retrieves SentinelOne agents with flexible filtering options
.DESCRIPTION
Queries all agents across sites with filtering by status, OS, version, site, etc.
Supports export to CSV/JSON and integration with other automation.
.PARAMETER SiteId
Filter by specific site ID
.PARAMETER SiteName
Filter by site name (partial match)
.PARAMETER Status
Filter by agent status (online, offline, all)
.PARAMETER OSType
Filter by OS type (windows, linux, macos)
.PARAMETER MinVersion
Filter agents below this version
.PARAMETER Infected
Filter for infected agents only
.PARAMETER ExportPath
Path to export results (CSV or JSON based on extension)
.EXAMPLE
.\Get-S1Agents.ps1 -Status offline -ExportPath "C:\Reports\offline-agents.csv"
.EXAMPLE
.\Get-S1Agents.ps1 -SiteName "Contoso" -OSType windows
#>
[CmdletBinding()]
param(
[string]$SiteId,
[string]$SiteName,
[ValidateSet('online', 'offline', 'all')]
[string]$Status = 'all',
[ValidateSet('windows', 'linux', 'macos', 'all')]
[string]$OSType = 'all',
[string]$MinVersion,
[switch]$Infected,
[string]$ExportPath
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Starting agent inventory query" -Level INFO
# Build query parameters
$queryParams = @{
limit = 1000
}
if ($SiteId) { $queryParams.siteIds = $SiteId }
if ($Status -eq 'online') { $queryParams.isActive = $true }
if ($Status -eq 'offline') { $queryParams.isActive = $false }
if ($OSType -ne 'all') { $queryParams.osTypes = $OSType }
if ($Infected) { $queryParams.infected = $true }
# Get all agents
$agents = Get-S1AllPages -Endpoint "/web/api/v2.1/agents" -QueryParams $queryParams
# Filter by site name if specified
if ($SiteName) {
$agents = $agents | Where-Object { $_.siteName -like "*$SiteName*" }
}
# Filter by minimum version if specified
if ($MinVersion) {
$agents = $agents | Where-Object {
[version]$_.agentVersion -lt [version]$MinVersion
}
}
# Format output
$report = $agents | Select-Object @(
@{N='ComputerName'; E={$_.computerName}}
@{N='SiteName'; E={$_.siteName}}
@{N='GroupName'; E={$_.groupName}}
@{N='AgentVersion'; E={$_.agentVersion}}
@{N='OSType'; E={$_.osType}}
@{N='OSName'; E={$_.osName}}
@{N='IsActive'; E={$_.isActive}}
@{N='LastActive'; E={$_.lastActiveDate}}
@{N='Infected'; E={$_.infected}}
@{N='NetworkStatus'; E={$_.networkStatus}}
@{N='Domain'; E={$_.domain}}
@{N='ExternalIP'; E={$_.externalIp}}
@{N='AgentId'; E={$_.id}}
)
# Display summary
Write-S1Log -Message "Found $($report.Count) agents" -Level SUCCESS
Write-Host "`nAgent Summary:" -ForegroundColor Cyan
Write-Host " Total: $($report.Count)"
Write-Host " Online: $(($report | Where-Object IsActive).Count)"
Write-Host " Offline: $(($report | Where-Object {-not $_.IsActive}).Count)"
Write-Host " Infected: $(($report | Where-Object Infected).Count)"
# Export if path specified
if ($ExportPath) {
$extension = [System.IO.Path]::GetExtension($ExportPath)
if ($extension -eq '.json') {
$report | ConvertTo-Json -Depth 5 | Out-File $ExportPath -Encoding UTF8
} else {
$report | Export-Csv -Path $ExportPath -NoTypeInformation
}
Write-S1Log -Message "Exported to: $ExportPath" -Level SUCCESS
}
return $report
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Get-S1AgentHealth.ps1 - Agent Health Check Automation
#Requires -Version 5.1
<#
.SYNOPSIS
Performs comprehensive health check on SentinelOne agents
.DESCRIPTION
Checks agent connectivity, version compliance, policy sync, and scan status.
Generates health report and can send alerts for critical issues.
.PARAMETER SiteId
Limit check to specific site
.PARAMETER AlertEmail
Email address for critical alerts
.PARAMETER VersionThreshold
Minimum acceptable agent version
.EXAMPLE
.\Get-S1AgentHealth.ps1 -VersionThreshold "23.4.2.0" -AlertEmail "soc@company.com"
#>
[CmdletBinding()]
param(
[string]$SiteId,
[string]$AlertEmail,
[string]$VersionThreshold = "23.3.0.0",
[int]$OfflineThresholdHours = 24,
[string]$ReportPath = "C:\BIN\LOGS\S1-HealthReport-$(Get-Date -Format 'yyyyMMdd').html"
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
function Get-AgentHealthStatus {
param($Agent)
$issues = @()
$severity = 'Healthy'
# Check online status
if (-not $Agent.isActive) {
$lastActive = [datetime]$Agent.lastActiveDate
$hoursOffline = (Get-Date) - $lastActive | Select-Object -ExpandProperty TotalHours
if ($hoursOffline -gt $OfflineThresholdHours) {
$issues += "Offline for $([math]::Round($hoursOffline, 1)) hours"
$severity = 'Warning'
}
if ($hoursOffline -gt ($OfflineThresholdHours * 7)) {
$severity = 'Critical'
}
}
# Check version
if ([version]$Agent.agentVersion -lt [version]$VersionThreshold) {
$issues += "Outdated version: $($Agent.agentVersion)"
if ($severity -ne 'Critical') { $severity = 'Warning' }
}
# Check infected status
if ($Agent.infected) {
$issues += "INFECTED - Active threat detected"
$severity = 'Critical'
}
# Check network quarantine
if ($Agent.networkStatus -eq 'quarantined') {
$issues += "Network quarantined"
$severity = 'Critical'
}
# Check scan status
if ($Agent.scanStatus -eq 'aborted' -or $Agent.scanStatus -eq 'failed') {
$issues += "Scan status: $($Agent.scanStatus)"
if ($severity -ne 'Critical') { $severity = 'Warning' }
}
return @{
Severity = $severity
Issues = $issues -join "; "
IssueCount = $issues.Count
}
}
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Starting agent health check" -Level INFO
$queryParams = @{}
if ($SiteId) { $queryParams.siteIds = $SiteId }
$agents = Get-S1AllPages -Endpoint "/web/api/v2.1/agents" -QueryParams $queryParams
$healthReport = foreach ($agent in $agents) {
$health = Get-AgentHealthStatus -Agent $agent
[PSCustomObject]@{
ComputerName = $agent.computerName
SiteName = $agent.siteName
AgentVersion = $agent.agentVersion
IsActive = $agent.isActive
LastActive = $agent.lastActiveDate
Infected = $agent.infected
NetworkStatus = $agent.networkStatus
Severity = $health.Severity
Issues = $health.Issues
AgentId = $agent.id
}
}
# Categorize results
$critical = $healthReport | Where-Object { $_.Severity -eq 'Critical' }
$warning = $healthReport | Where-Object { $_.Severity -eq 'Warning' }
$healthy = $healthReport | Where-Object { $_.Severity -eq 'Healthy' }
# Generate HTML report
$html = @"
<!DOCTYPE html>
\<html\>
\<head\>
\<title\>SentinelOne Agent Health Report</title>
\<style\>
body { font-family: 'Segoe UI', Arial, sans-serif; margin: 20px; background: #f5f5f5; }
.container { max-width: 1200px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #333; border-bottom: 2px solid #6B2D9E; padding-bottom: 10px; }
.summary { display: flex; gap: 20px; margin: 20px 0; }
.stat-box { flex: 1; padding: 20px; border-radius: 8px; text-align: center; }
.critical { background: #FFE5E5; border: 2px solid #FF4444; }
.warning { background: #FFF3E0; border: 2px solid #FF9800; }
.healthy { background: #E8F5E9; border: 2px solid #4CAF50; }
.stat-number { font-size: 36px; font-weight: bold; }
.stat-label { font-size: 14px; color: #666; }
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th { background: #6B2D9E; color: white; padding: 12px; text-align: left; }
td { padding: 10px; border-bottom: 1px solid #ddd; }
tr:hover { background: #f9f9f9; }
.severity-critical { color: #FF4444; font-weight: bold; }
.severity-warning { color: #FF9800; font-weight: bold; }
.severity-healthy { color: #4CAF50; }
.timestamp { color: #999; font-size: 12px; margin-top: 20px; }
</style>
</head>
\<body\>
<div class="container">
<h1>SentinelOne Agent Health Report</h1>
<div class="summary">
<div class="stat-box critical">
<div class="stat-number">$($critical.Count)</div>
<div class="stat-label">Critical Issues</div>
</div>
<div class="stat-box warning">
<div class="stat-number">$($warning.Count)</div>
<div class="stat-label">Warnings</div>
</div>
<div class="stat-box healthy">
<div class="stat-number">$($healthy.Count)</div>
<div class="stat-label">Healthy</div>
</div>
</div>
<h2>Critical Issues</h2>
\<table\>
\<tr\>\<th\>Computer</th>\<th\>Site</th>\<th\>Version</th>\<th\>Status</th>\<th\>Issues</th></tr>
$(foreach ($item in $critical) {
"\<tr\>\<td\>$($item.ComputerName)</td>\<td\>$($item.SiteName)</td>\<td\>$($item.AgentVersion)</td>\<td\>$(if($item.IsActive){'Online'}else{'Offline'})</td>\<td\>$($item.Issues)</td></tr>"
})
</table>
<h2>Warnings</h2>
\<table\>
\<tr\>\<th\>Computer</th>\<th\>Site</th>\<th\>Version</th>\<th\>Status</th>\<th\>Issues</th></tr>
$(foreach ($item in ($warning | Select-Object -First 50)) {
"\<tr\>\<td\>$($item.ComputerName)</td>\<td\>$($item.SiteName)</td>\<td\>$($item.AgentVersion)</td>\<td\>$(if($item.IsActive){'Online'}else{'Offline'})</td>\<td\>$($item.Issues)</td></tr>"
})
</table>
<p class="timestamp">Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Total Agents: $($healthReport.Count)</p>
</div>
</body>
</html>
"@
$html | Out-File $ReportPath -Encoding UTF8
Write-S1Log -Message "Health report saved to: $ReportPath" -Level SUCCESS
# Send alert if critical issues found
if ($AlertEmail -and $critical.Count -gt 0) {
$subject = "ALERT: $($critical.Count) SentinelOne Agents with Critical Issues"
$body = "Critical issues detected on the following agents:`n`n"
$body += ($critical | ForEach-Object { "$($_.ComputerName) - $($_.Issues)" }) -join "`n"
# Uncomment to enable email alerts
# Send-MailMessage -To $AlertEmail -From "sentinelone@company.com" -Subject $subject -Body $body -SmtpServer "smtp.company.com"
Write-S1Log -Message "Alert would be sent to $AlertEmail" -Level WARN
}
# Console summary
Write-Host "`nHealth Check Summary:" -ForegroundColor Cyan
Write-Host " Critical: $($critical.Count)" -ForegroundColor Red
Write-Host " Warning: $($warning.Count)" -ForegroundColor Yellow
Write-Host " Healthy: $($healthy.Count)" -ForegroundColor Green
return $healthReport
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Update-S1Agents.ps1 - Bulk Agent Upgrade Script
#Requires -Version 5.1
<#
.SYNOPSIS
Initiates bulk agent upgrades across sites
.DESCRIPTION
Upgrades agents to specified version with scheduling and rollback options.
Supports filtering by site, group, OS type, and current version.
.PARAMETER TargetVersion
Target agent version (or 'latest' for newest GA)
.PARAMETER SiteIds
Array of site IDs to upgrade
.PARAMETER MaxConcurrent
Maximum concurrent upgrades per site
.PARAMETER ScheduleTime
Schedule upgrade for specific time (UTC)
.EXAMPLE
.\Update-S1Agents.ps1 -TargetVersion "latest" -SiteIds @("site1","site2")
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[string]$TargetVersion,
[Parameter(Mandatory = $false)]
[string[]]$SiteIds,
[Parameter(Mandatory = $false)]
[string[]]$GroupIds,
[Parameter(Mandatory = $false)]
[ValidateSet('windows', 'linux', 'macos')]
[string]$OSType = 'windows',
[Parameter(Mandatory = $false)]
[int]$MaxConcurrent = 50,
[Parameter(Mandatory = $false)]
[datetime]$ScheduleTime,
[Parameter(Mandatory = $false)]
[switch]$Force
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Starting bulk agent upgrade process" -Level INFO
# Get available packages if 'latest' specified
if ($TargetVersion -eq 'latest') {
$packages = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/update/agent/packages" -QueryParams @{
osTypes = $OSType
status = 'ga'
sortBy = 'version'
sortOrder = 'desc'
limit = 1
}
if ($packages.data.Count -eq 0) {
throw "No GA packages found for $OSType"
}
$TargetVersion = $packages.data[0].version
$packageId = $packages.data[0].id
Write-S1Log -Message "Latest GA version for $OSType`: $TargetVersion" -Level INFO
} else {
# Find specific package
$packages = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/update/agent/packages" -QueryParams @{
osTypes = $OSType
version = $TargetVersion
}
if ($packages.data.Count -eq 0) {
throw "Package version $TargetVersion not found for $OSType"
}
$packageId = $packages.data[0].id
}
# Build agent filter
$agentQuery = @{
osTypes = $OSType
isActive = $true
}
if ($SiteIds) { $agentQuery.siteIds = $SiteIds -join ',' }
if ($GroupIds) { $agentQuery.groupIds = $GroupIds -join ',' }
# Get agents needing upgrade
$agents = Get-S1AllPages -Endpoint "/web/api/v2.1/agents" -QueryParams $agentQuery
$agentsToUpgrade = $agents | Where-Object {
[version]$_.agentVersion -lt [version]$TargetVersion
}
if ($agentsToUpgrade.Count -eq 0) {
Write-S1Log -Message "All agents are already at version $TargetVersion or higher" -Level SUCCESS
return
}
Write-S1Log -Message "Found $($agentsToUpgrade.Count) agents requiring upgrade to $TargetVersion" -Level INFO
# Confirm upgrade
if (-not $Force -and -not $PSCmdlet.ShouldProcess("$($agentsToUpgrade.Count) agents", "Upgrade to $TargetVersion")) {
Write-S1Log -Message "Upgrade cancelled by user" -Level WARN
return
}
# Process in batches
$batches = [System.Collections.ArrayList]@()
for ($i = 0; $i -lt $agentsToUpgrade.Count; $i += $MaxConcurrent) {
$batch = $agentsToUpgrade | Select-Object -Skip $i -First $MaxConcurrent
[void]$batches.Add($batch)
}
Write-S1Log -Message "Processing $($batches.Count) batches of up to $MaxConcurrent agents" -Level INFO
$results = @()
$batchNum = 0
foreach ($batch in $batches) {
$batchNum++
Write-S1Log -Message "Processing batch $batchNum of $($batches.Count)" -Level INFO
$agentIds = $batch | Select-Object -ExpandProperty id
$upgradeBody = @{
data = @{
packageId = $packageId
}
filter = @{
ids = $agentIds
}
}
if ($ScheduleTime) {
$upgradeBody.data.scheduleTime = $ScheduleTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
}
try {
$response = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents/actions/update-software" `
-Method POST -Body $upgradeBody
$results += [PSCustomObject]@{
Batch = $batchNum
AgentCount = $agentIds.Count
Affected = $response.data.affected
Status = 'Initiated'
}
Write-S1Log -Message "Batch $batchNum`: Upgrade initiated for $($response.data.affected) agents" -Level SUCCESS
}
catch {
$results += [PSCustomObject]@{
Batch = $batchNum
AgentCount = $agentIds.Count
Affected = 0
Status = "Failed: $($_.Exception.Message)"
}
Write-S1Log -Message "Batch $batchNum failed: $($_.Exception.Message)" -Level ERROR
}
# Rate limiting between batches
if ($batchNum -lt $batches.Count) {
Start-Sleep -Seconds 2
}
}
# Summary
$totalAffected = ($results | Measure-Object -Property Affected -Sum).Sum
Write-Host "`nUpgrade Summary:" -ForegroundColor Cyan
Write-Host " Target Version: $TargetVersion"
Write-Host " Total Agents Upgraded: $totalAffected"
Write-Host " Batches Processed: $($batches.Count)"
return $results
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Get-S1UninstallPassphrase.ps1 - Uninstall Passphrase Retrieval
#Requires -Version 5.1
<#
.SYNOPSIS
Retrieves agent uninstall passphrases
.DESCRIPTION
Gets uninstall passphrase for specific agents or exports all for a site.
Useful for agent removal and reimaging scenarios.
.PARAMETER ComputerName
Computer name to retrieve passphrase for
.PARAMETER AgentId
Agent ID to retrieve passphrase for
.PARAMETER SiteId
Site ID to export all passphrases for
.PARAMETER ExportPath
Path to export passphrases (encrypted CSV)
.EXAMPLE
.\Get-S1UninstallPassphrase.ps1 -ComputerName "WKS-001"
.EXAMPLE
.\Get-S1UninstallPassphrase.ps1 -SiteId "12345" -ExportPath "C:\Secure\passphrases.csv"
#>
[CmdletBinding()]
param(
[Parameter(ParameterSetName = 'ByName')]
[string]$ComputerName,
[Parameter(ParameterSetName = 'ById')]
[string]$AgentId,
[Parameter(ParameterSetName = 'BySite')]
[string]$SiteId,
[Parameter(ParameterSetName = 'BySite')]
[string]$ExportPath
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Retrieving uninstall passphrase(s)" -Level INFO
$results = @()
if ($ComputerName) {
# Find agent by computer name
$agents = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents" -QueryParams @{
computerName = $ComputerName
}
if ($agents.data.Count -eq 0) {
throw "Agent not found: $ComputerName"
}
$AgentId = $agents.data[0].id
}
if ($AgentId) {
# Get passphrase for single agent
$passphrase = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents/passphrases" -QueryParams @{
ids = $AgentId
}
if ($passphrase.data.Count -gt 0) {
$agentInfo = $agents.data[0]
$results += [PSCustomObject]@{
ComputerName = $agentInfo.computerName
SiteName = $agentInfo.siteName
Passphrase = $passphrase.data[0].passphrase
AgentId = $AgentId
}
Write-Host "`nUninstall Passphrase for $($agentInfo.computerName):" -ForegroundColor Cyan
Write-Host " Passphrase: $($passphrase.data[0].passphrase)" -ForegroundColor Yellow
Write-Host " Site: $($agentInfo.siteName)"
}
}
elseif ($SiteId) {
# Get all agents for site
$agents = Get-S1AllPages -Endpoint "/web/api/v2.1/agents" -QueryParams @{
siteIds = $SiteId
}
Write-S1Log -Message "Found $($agents.Count) agents in site" -Level INFO
# Get passphrases in batches
for ($i = 0; $i -lt $agents.Count; $i += 100) {
$batch = $agents | Select-Object -Skip $i -First 100
$agentIds = ($batch | Select-Object -ExpandProperty id) -join ','
$passphrases = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents/passphrases" -QueryParams @{
ids = $agentIds
}
foreach ($pp in $passphrases.data) {
$agent = $batch | Where-Object { $_.id -eq $pp.id }
$results += [PSCustomObject]@{
ComputerName = $agent.computerName
SiteName = $agent.siteName
Passphrase = $pp.passphrase
AgentId = $pp.id
}
}
Write-Verbose "Processed $([math]::Min($i + 100, $agents.Count)) of $($agents.Count) agents"
}
if ($ExportPath) {
# Export with warning about sensitivity
Write-Warning "Exporting uninstall passphrases - ensure secure storage!"
$results | Export-Csv -Path $ExportPath -NoTypeInformation
Write-S1Log -Message "Passphrases exported to: $ExportPath" -Level SUCCESS
}
}
return $results
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Remove-S1Agent.ps1 - Agent Decommission Script
#Requires -Version 5.1
<#
.SYNOPSIS
Decommissions SentinelOne agents
.DESCRIPTION
Removes agents from management, optionally triggering uninstall.
Use for endpoint retirement, reimaging, or cleanup scenarios.
.PARAMETER ComputerName
Computer name(s) to decommission
.PARAMETER AgentId
Agent ID(s) to decommission
.PARAMETER Uninstall
Send uninstall command (requires agent online)
.PARAMETER Force
Skip confirmation prompt
.EXAMPLE
.\Remove-S1Agent.ps1 -ComputerName "WKS-001","WKS-002" -Uninstall
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[Parameter(ParameterSetName = 'ByName')]
[string[]]$ComputerName,
[Parameter(ParameterSetName = 'ById')]
[string[]]$AgentId,
[switch]$Uninstall,
[switch]$Force
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Starting agent decommission process" -Level INFO
$agentIds = @()
if ($ComputerName) {
foreach ($name in $ComputerName) {
$agents = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents" -QueryParams @{
computerName = $name
}
if ($agents.data.Count -eq 0) {
Write-S1Log -Message "Agent not found: $name" -Level WARN
continue
}
$agentIds += $agents.data[0].id
Write-S1Log -Message "Found agent: $name (ID: $($agents.data[0].id))" -Level INFO
}
} else {
$agentIds = $AgentId
}
if ($agentIds.Count -eq 0) {
Write-S1Log -Message "No agents to process" -Level WARN
return
}
# Confirmation
if (-not $Force) {
Write-Host "`nAgents to decommission: $($agentIds.Count)" -ForegroundColor Yellow
if (-not $PSCmdlet.ShouldProcess("$($agentIds.Count) agents", "Decommission")) {
return
}
}
$results = @()
# Initiate uninstall if requested
if ($Uninstall) {
Write-S1Log -Message "Initiating remote uninstall for $($agentIds.Count) agents" -Level INFO
$uninstallBody = @{
filter = @{
ids = $agentIds
}
}
try {
$response = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents/actions/uninstall" `
-Method POST -Body $uninstallBody
Write-S1Log -Message "Uninstall initiated for $($response.data.affected) agents" -Level SUCCESS
$results += [PSCustomObject]@{
Action = 'Uninstall'
AgentCount = $agentIds.Count
Affected = $response.data.affected
Status = 'Initiated'
}
}
catch {
Write-S1Log -Message "Uninstall failed: $($_.Exception.Message)" -Level ERROR
$results += [PSCustomObject]@{
Action = 'Uninstall'
AgentCount = $agentIds.Count
Affected = 0
Status = "Failed: $($_.Exception.Message)"
}
}
}
# Decommission (remove from console)
Write-S1Log -Message "Decommissioning $($agentIds.Count) agents from console" -Level INFO
$decommBody = @{
filter = @{
ids = $agentIds
}
}
try {
$response = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents/actions/decommission" `
-Method POST -Body $decommBody
Write-S1Log -Message "Decommissioned $($response.data.affected) agents" -Level SUCCESS
$results += [PSCustomObject]@{
Action = 'Decommission'
AgentCount = $agentIds.Count
Affected = $response.data.affected
Status = 'Completed'
}
}
catch {
Write-S1Log -Message "Decommission failed: $($_.Exception.Message)" -Level ERROR
$results += [PSCustomObject]@{
Action = 'Decommission'
AgentCount = $agentIds.Count
Affected = 0
Status = "Failed: $($_.Exception.Message)"
}
}
return $results
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}3. THREAT MANAGEMENT SCRIPTS
Get-S1Threats.ps1 - Active Threat Retrieval
#Requires -Version 5.1
<#
.SYNOPSIS
Retrieves threats across all sites with filtering options
.DESCRIPTION
Queries active and historical threats with filtering by status,
severity, date range, and site.
.PARAMETER Status
Filter by resolution status (unresolved, resolved, all)
.PARAMETER Severity
Filter by AI confidence level
.PARAMETER DaysBack
Number of days to look back (default: 7)
.PARAMETER SiteId
Filter by specific site
.PARAMETER ExportPath
Path to export threat data
.EXAMPLE
.\Get-S1Threats.ps1 -Status unresolved -DaysBack 1
#>
[CmdletBinding()]
param(
[ValidateSet('unresolved', 'resolved', 'all')]
[string]$Status = 'unresolved',
[ValidateSet('malicious', 'suspicious', 'all')]
[string]$Severity = 'all',
[int]$DaysBack = 7,
[string]$SiteId,
[string]$ExportPath
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Querying threats (Status: $Status, Days: $DaysBack)" -Level INFO
$queryParams = @{
createdAt__gte = (Get-Date).AddDays(-$DaysBack).ToString("yyyy-MM-ddT00:00:00Z")
}
if ($Status -ne 'all') {
$queryParams.resolved = ($Status -eq 'resolved')
}
if ($Severity -ne 'all') {
$queryParams.confidenceLevels = $Severity
}
if ($SiteId) {
$queryParams.siteIds = $SiteId
}
$threats = Get-S1AllPages -Endpoint "/web/api/v2.1/threats" -QueryParams $queryParams
$report = $threats | Select-Object @(
@{N='ThreatId'; E={$_.id}}
@{N='ThreatName'; E={$_.threatInfo.threatName}}
@{N='Classification'; E={$_.threatInfo.classification}}
@{N='Confidence'; E={$_.threatInfo.confidenceLevel}}
@{N='ComputerName'; E={$_.agentRealtimeInfo.agentComputerName}}
@{N='SiteName'; E={$_.agentRealtimeInfo.siteName}}
@{N='FilePath'; E={$_.threatInfo.filePath}}
@{N='SHA256'; E={$_.threatInfo.sha256}}
@{N='MitigationStatus'; E={$_.threatInfo.mitigationStatus}}
@{N='AnalystVerdict'; E={$_.threatInfo.analystVerdict}}
@{N='Resolved'; E={$_.threatInfo.resolved}}
@{N='CreatedAt'; E={$_.threatInfo.createdAt}}
@{N='Username'; E={$_.threatInfo.processUser}}
)
# Summary by classification
Write-Host "`nThreat Summary:" -ForegroundColor Cyan
Write-Host " Total: $($report.Count)"
$byClass = $report | Group-Object Classification
foreach ($group in $byClass) {
Write-Host " $($group.Name): $($group.Count)"
}
# Export if requested
if ($ExportPath) {
$report | Export-Csv -Path $ExportPath -NoTypeInformation
Write-S1Log -Message "Threats exported to: $ExportPath" -Level SUCCESS
}
return $report
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Invoke-S1ThreatMitigation.ps1 - Automated Threat Response
#Requires -Version 5.1
<#
.SYNOPSIS
Automates threat mitigation actions
.DESCRIPTION
Performs bulk threat mitigation including kill, quarantine, remediate,
and rollback actions. Supports automatic resolution based on rules.
.PARAMETER ThreatIds
Specific threat IDs to mitigate
.PARAMETER Action
Mitigation action to perform
.PARAMETER AutoResolve
Automatically resolve after successful mitigation
.PARAMETER Classification
Filter threats by classification for bulk action
.EXAMPLE
.\Invoke-S1ThreatMitigation.ps1 -ThreatIds @("123","456") -Action quarantine
.EXAMPLE
.\Invoke-S1ThreatMitigation.ps1 -Classification "pup" -Action remediate -AutoResolve
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(ParameterSetName = 'ById')]
[string[]]$ThreatIds,
[Parameter(ParameterSetName = 'ByFilter')]
[ValidateSet('malware', 'pup', 'suspicious', 'trojan', 'ransomware')]
[string]$Classification,
[Parameter(ParameterSetName = 'ByFilter')]
[int]$DaysBack = 1,
[Parameter(Mandatory = $true)]
[ValidateSet('kill', 'quarantine', 'remediate', 'rollback', 'disconnect-from-network')]
[string]$Action,
[switch]$AutoResolve,
[ValidateSet('true_positive', 'false_positive', 'suspicious', 'undefined')]
[string]$Verdict = 'true_positive'
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Starting threat mitigation process" -Level INFO
# Get threats if filtering
if ($Classification) {
$queryParams = @{
resolved = $false
classification = $Classification
createdAt__gte = (Get-Date).AddDays(-$DaysBack).ToString("yyyy-MM-ddT00:00:00Z")
}
$threats = Get-S1AllPages -Endpoint "/web/api/v2.1/threats" -QueryParams $queryParams
$ThreatIds = $threats | Select-Object -ExpandProperty id
}
if ($ThreatIds.Count -eq 0) {
Write-S1Log -Message "No threats to process" -Level WARN
return
}
Write-S1Log -Message "Processing $($ThreatIds.Count) threats with action: $Action" -Level INFO
# Map action to endpoint
$actionEndpoint = switch ($Action) {
'kill' { 'kill' }
'quarantine' { 'quarantine' }
'remediate' { 'remediate' }
'rollback' { 'rollback' }
'disconnect-from-network' { 'disconnect-from-network' }
}
if (-not $PSCmdlet.ShouldProcess("$($ThreatIds.Count) threats", $Action)) {
return
}
# Perform mitigation action
$mitigationBody = @{
filter = @{
ids = $ThreatIds
}
}
try {
$response = Invoke-S1ApiRequest `
-Endpoint "/web/api/v2.1/threats/actions/$actionEndpoint" `
-Method POST -Body $mitigationBody
Write-S1Log -Message "$Action action applied to $($response.data.affected) threats" -Level SUCCESS
}
catch {
Write-S1Log -Message "Mitigation action failed: $($_.Exception.Message)" -Level ERROR
throw
}
# Auto-resolve if requested
if ($AutoResolve) {
Start-Sleep -Seconds 2 # Allow mitigation to process
$resolveBody = @{
filter = @{
ids = $ThreatIds
}
data = @{
analystVerdict = $Verdict
}
}
try {
$response = Invoke-S1ApiRequest `
-Endpoint "/web/api/v2.1/threats/analyst-verdict" `
-Method POST -Body $resolveBody
Write-S1Log -Message "Resolved $($response.data.affected) threats as $Verdict" -Level SUCCESS
}
catch {
Write-S1Log -Message "Resolution failed: $($_.Exception.Message)" -Level ERROR
}
}
return @{
Action = $Action
ThreatsProcessed = $ThreatIds.Count
AutoResolved = $AutoResolve
}
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Export-S1ThreatIntelligence.ps1 - Threat Intelligence Export
#Requires -Version 5.1
<#
.SYNOPSIS
Exports threat intelligence data including IOCs
.DESCRIPTION
Extracts threat indicators (hashes, IPs, domains) from threats
for use in other security tools or threat intel platforms.
.PARAMETER DaysBack
Number of days to analyze
.PARAMETER Format
Output format (csv, json, stix)
.PARAMETER ExportPath
Path to save export
.EXAMPLE
.\Export-S1ThreatIntelligence.ps1 -DaysBack 30 -Format json -ExportPath "C:\Intel\iocs.json"
#>
[CmdletBinding()]
param(
[int]$DaysBack = 30,
[ValidateSet('csv', 'json', 'stix')]
[string]$Format = 'csv',
[Parameter(Mandatory = $true)]
[string]$ExportPath
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Extracting threat intelligence for last $DaysBack days" -Level INFO
$threats = Get-S1AllPages -Endpoint "/web/api/v2.1/threats" -QueryParams @{
createdAt__gte = (Get-Date).AddDays(-$DaysBack).ToString("yyyy-MM-ddT00:00:00Z")
}
Write-S1Log -Message "Processing $($threats.Count) threats" -Level INFO
$iocs = @()
foreach ($threat in $threats) {
# File hash IOCs
if ($threat.threatInfo.sha256) {
$iocs += [PSCustomObject]@{
Type = 'sha256'
Value = $threat.threatInfo.sha256
ThreatName = $threat.threatInfo.threatName
Classification = $threat.threatInfo.classification
Confidence = $threat.threatInfo.confidenceLevel
FirstSeen = $threat.threatInfo.createdAt
Source = 'SentinelOne'
}
}
if ($threat.threatInfo.sha1) {
$iocs += [PSCustomObject]@{
Type = 'sha1'
Value = $threat.threatInfo.sha1
ThreatName = $threat.threatInfo.threatName
Classification = $threat.threatInfo.classification
Confidence = $threat.threatInfo.confidenceLevel
FirstSeen = $threat.threatInfo.createdAt
Source = 'SentinelOne'
}
}
if ($threat.threatInfo.md5) {
$iocs += [PSCustomObject]@{
Type = 'md5'
Value = $threat.threatInfo.md5
ThreatName = $threat.threatInfo.threatName
Classification = $threat.threatInfo.classification
Confidence = $threat.threatInfo.confidenceLevel
FirstSeen = $threat.threatInfo.createdAt
Source = 'SentinelOne'
}
}
}
# Deduplicate
$uniqueIOCs = $iocs | Sort-Object Type, Value -Unique
Write-S1Log -Message "Extracted $($uniqueIOCs.Count) unique IOCs" -Level SUCCESS
# Export based on format
switch ($Format) {
'csv' {
$uniqueIOCs | Export-Csv -Path $ExportPath -NoTypeInformation
}
'json' {
$uniqueIOCs | ConvertTo-Json -Depth 5 | Out-File $ExportPath -Encoding UTF8
}
'stix' {
# Basic STIX 2.1 format
$stixBundle = @{
type = 'bundle'
id = "bundle--$(New-Guid)"
objects = @()
}
foreach ($ioc in $uniqueIOCs) {
$stixBundle.objects += @{
type = 'indicator'
id = "indicator--$(New-Guid)"
created = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
modified = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
name = $ioc.ThreatName
pattern = "[file:hashes.'$($ioc.Type.ToUpper())' = '$($ioc.Value)']"
pattern_type = 'stix'
valid_from = $ioc.FirstSeen
}
}
$stixBundle | ConvertTo-Json -Depth 10 | Out-File $ExportPath -Encoding UTF8
}
}
Write-S1Log -Message "IOCs exported to: $ExportPath" -Level SUCCESS
# Summary
Write-Host "`nIOC Summary:" -ForegroundColor Cyan
$uniqueIOCs | Group-Object Type | ForEach-Object {
Write-Host " $($_.Name): $($_.Count)"
}
return $uniqueIOCs
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}4. DEEP VISIBILITY AUTOMATION
Invoke-S1ThreatHunt.ps1 - Scheduled Threat Hunting
#Requires -Version 5.1
<#
.SYNOPSIS
Executes scheduled threat hunting queries via Deep Visibility
.DESCRIPTION
Runs predefined or custom hunting queries, exports results,
and can trigger alerts for findings.
.PARAMETER HuntName
Name of predefined hunt (or 'custom' for custom query)
.PARAMETER CustomQuery
Custom S1QL query string
.PARAMETER SiteId
Limit hunt to specific site
.PARAMETER AlertOnFindings
Send alert if results found
.EXAMPLE
.\Invoke-S1ThreatHunt.ps1 -HuntName "EncodedPowerShell"
.EXAMPLE
.\Invoke-S1ThreatHunt.ps1 -CustomQuery 'SrcProcName = "mimikatz.exe"' -AlertOnFindings
#>
[CmdletBinding()]
param(
[Parameter(ParameterSetName = 'Predefined')]
[ValidateSet('EncodedPowerShell', 'LSASSAccess', 'ScheduledTaskCreation',
'EventLogClearing', 'WebShellCreation', 'CertutilDownload',
'WMIPersistence', 'SuspiciousParentChild', 'All')]
[string]$HuntName,
[Parameter(ParameterSetName = 'Custom')]
[string]$CustomQuery,
[string]$SiteId,
[int]$HoursBack = 24,
[switch]$AlertOnFindings,
[string]$AlertEmail,
[string]$OutputPath = "C:\BIN\LOGS\ThreatHunting"
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
# Predefined hunting queries
$HuntingQueries = @{
EncodedPowerShell = @{
Name = "Encoded PowerShell Execution"
Query = 'EventType = "Process Creation" AND SrcProcName = "powershell.exe" AND (SrcProcCmdLine ContainsCIS "-enc" OR SrcProcCmdLine ContainsCIS "-encodedcommand" OR SrcProcCmdLine ContainsCIS "frombase64string")'
MITRE = "T1059.001"
Severity = "High"
}
LSASSAccess = @{
Name = "LSASS Memory Access"
Query = 'TgtProcName = "lsass.exe" AND EventType = "Open Remote Process Handle" AND SrcProcName Not In ("svchost.exe","lsm.exe","wmiprvse.exe","csrss.exe","MsMpEng.exe")'
MITRE = "T1003.001"
Severity = "Critical"
}
ScheduledTaskCreation = @{
Name = "Suspicious Scheduled Task Creation"
Query = '(TgtProcName = "schtasks.exe" AND TgtProcCmdLine ContainsCIS "/create") AND SrcProcParentName Not In ("services.exe","svchost.exe","msiexec.exe")'
MITRE = "T1053.005"
Severity = "Medium"
}
EventLogClearing = @{
Name = "Windows Event Log Clearing"
Query = '(TgtProcName = "wevtutil.exe" AND TgtProcCmdLine ContainsCIS "cl ") OR (SrcProcCmdLine ContainsCIS "Clear-EventLog")'
MITRE = "T1070.001"
Severity = "High"
}
WebShellCreation = @{
Name = "Web Shell File Creation"
Query = 'EventType = "File Creation" AND FileFullName ContainsCIS "inetpub\wwwroot" AND TgtFileExtension In Contains Anycase ("jsp","aspx","php","asp")'
MITRE = "T1505.003"
Severity = "Critical"
}
CertutilDownload = @{
Name = "Certutil File Download"
Query = 'TgtProcName = "certutil.exe" AND (TgtProcCmdLine ContainsCIS "urlcache" OR TgtProcCmdLine ContainsCIS "verifyctl")'
MITRE = "T1105"
Severity = "High"
}
WMIPersistence = @{
Name = "WMI Event Subscription Persistence"
Query = 'SrcProcCmdLine ContainsCIS "New-CimInstance" AND SrcProcCmdLine ContainsCIS "root/subscription"'
MITRE = "T1546.003"
Severity = "High"
}
SuspiciousParentChild = @{
Name = "Office Spawning Shell"
Query = 'SrcProcName In ("winword.exe","excel.exe","powerpnt.exe","outlook.exe") AND TgtProcName In ("cmd.exe","powershell.exe","wscript.exe","cscript.exe","mshta.exe")'
MITRE = "T1566.001"
Severity = "High"
}
}
function Invoke-DVQuery {
param(
[string]$Query,
[string]$FromDate,
[string]$ToDate,
[string]$SiteId
)
$body = @{
query = $Query
fromDate = $FromDate
toDate = $ToDate
limit = 10000
}
if ($SiteId) {
$body.siteIds = @($SiteId)
}
$response = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/dv/query" `
-Method POST -Body $body
return $response.data
}
try {
Connect-S1Console -UseStoredCredentials
# Ensure output directory exists
if (-not (Test-Path $OutputPath)) {
New-Item -Path $OutputPath -ItemType Directory -Force | Out-Null
}
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$fromDate = (Get-Date).AddHours(-$HoursBack).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
$toDate = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
$huntsToRun = @()
if ($CustomQuery) {
$huntsToRun += @{
Name = "CustomQuery"
Query = $CustomQuery
MITRE = "Custom"
Severity = "Medium"
}
}
elseif ($HuntName -eq 'All') {
$huntsToRun = $HuntingQueries.Values
}
else {
$huntsToRun += $HuntingQueries[$HuntName]
}
Write-S1Log -Message "Starting threat hunt - $($huntsToRun.Count) queries" -Level INFO
$summaryResults = @()
$allFindings = @()
foreach ($hunt in $huntsToRun) {
Write-S1Log -Message "Running hunt: $($hunt.Name)" -Level INFO
try {
$results = Invoke-DVQuery -Query $hunt.Query -FromDate $fromDate -ToDate $toDate -SiteId $SiteId
$resultCount = if ($results) { $results.Count } else { 0 }
$summaryResults += [PSCustomObject]@{
HuntName = $hunt.Name
MITRE = $hunt.MITRE
Severity = $hunt.Severity
ResultCount = $resultCount
Status = if ($resultCount -gt 0) { "FINDINGS" } else { "Clear" }
}
if ($resultCount -gt 0) {
Write-S1Log -Message " [!] Found $resultCount results" -Level WARN
# Export individual hunt results
$huntFile = Join-Path $OutputPath "$($hunt.Name -replace ' ','_')_$timestamp.csv"
$results | Export-Csv -Path $huntFile -NoTypeInformation
$allFindings += $results | ForEach-Object {
$_ | Add-Member -NotePropertyName 'HuntName' -NotePropertyValue $hunt.Name -PassThru
}
}
else {
Write-S1Log -Message " [OK] No findings" -Level SUCCESS
}
}
catch {
Write-S1Log -Message " Hunt failed: $($_.Exception.Message)" -Level ERROR
$summaryResults += [PSCustomObject]@{
HuntName = $hunt.Name
MITRE = $hunt.MITRE
Severity = $hunt.Severity
ResultCount = -1
Status = "ERROR"
}
}
Start-Sleep -Seconds 2 # Rate limiting
}
# Export summary
$summaryFile = Join-Path $OutputPath "HuntingSummary_$timestamp.csv"
$summaryResults | Export-Csv -Path $summaryFile -NoTypeInformation
# Display summary
Write-Host "`n=== Threat Hunting Summary ===" -ForegroundColor Cyan
$summaryResults | Format-Table -AutoSize
# Alert if findings
$findingsCount = ($summaryResults | Where-Object { $_.Status -eq "FINDINGS" }).Count
if ($findingsCount -gt 0) {
Write-S1Log -Message "$findingsCount hunts returned findings - review required!" -Level WARN
if ($AlertOnFindings -and $AlertEmail) {
$subject = "[SentinelOne] Threat Hunt Findings - $findingsCount Detections"
$body = "Threat hunting completed with findings:`n`n"
$body += ($summaryResults | Where-Object { $_.Status -eq "FINDINGS" } |
ForEach-Object { "$($_.HuntName): $($_.ResultCount) results" }) -join "`n"
# Send-MailMessage -To $AlertEmail -From "sentinelone@company.com" -Subject $subject -Body $body -SmtpServer "smtp.company.com"
Write-S1Log -Message "Alert sent to $AlertEmail" -Level INFO
}
}
return $summaryResults
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Search-S1IOC.ps1 - IOC Hunting Across Fleet
#Requires -Version 5.1
<#
.SYNOPSIS
Searches for IOCs across the entire fleet using Deep Visibility
.DESCRIPTION
Hunts for file hashes, IPs, domains, and file names across all endpoints.
Supports bulk IOC import from file.
.PARAMETER IOCType
Type of IOC to search
.PARAMETER IOCValue
Single IOC value to search
.PARAMETER IOCFile
Path to file containing IOCs (one per line)
.PARAMETER DaysBack
Days to search back in telemetry
.EXAMPLE
.\Search-S1IOC.ps1 -IOCType sha256 -IOCValue "abc123..."
.EXAMPLE
.\Search-S1IOC.ps1 -IOCType domain -IOCFile "C:\Intel\bad-domains.txt"
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('sha256', 'sha1', 'md5', 'ip', 'domain', 'filename')]
[string]$IOCType,
[Parameter(ParameterSetName = 'Single')]
[string]$IOCValue,
[Parameter(ParameterSetName = 'Bulk')]
[string]$IOCFile,
[int]$DaysBack = 14,
[string]$SiteId,
[string]$ExportPath
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
function Build-IOCQuery {
param($Type, $Value)
switch ($Type) {
'sha256' { return "FileSha256 = `"$Value`"" }
'sha1' { return "FileSha1 = `"$Value`"" }
'md5' { return "FileMD5 = `"$Value`"" }
'ip' { return "DstIp = `"$Value`"" }
'domain' { return "DnsRequest ContainsCIS `"$Value`"" }
'filename' { return "FileName ContainsCIS `"$Value`"" }
}
}
try {
Connect-S1Console -UseStoredCredentials
# Get IOC list
$iocs = @()
if ($IOCFile) {
$iocs = Get-Content $IOCFile | Where-Object { $_ -and $_ -notmatch '^#' }
} else {
$iocs = @($IOCValue)
}
Write-S1Log -Message "Searching for $($iocs.Count) IOCs of type: $IOCType" -Level INFO
$fromDate = (Get-Date).AddDays(-$DaysBack).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
$toDate = (Get-Date).ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
$allResults = @()
foreach ($ioc in $iocs) {
$query = Build-IOCQuery -Type $IOCType -Value $ioc
try {
$body = @{
query = $query
fromDate = $fromDate
toDate = $toDate
limit = 1000
}
if ($SiteId) { $body.siteIds = @($SiteId) }
$response = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/dv/query" `
-Method POST -Body $body
$resultCount = if ($response.data) { $response.data.Count } else { 0 }
if ($resultCount -gt 0) {
Write-S1Log -Message "[!] Found $resultCount hits for: $ioc" -Level WARN
foreach ($hit in $response.data) {
$allResults += [PSCustomObject]@{
IOC = $ioc
IOCType = $IOCType
AgentName = $hit.agentName
SiteName = $hit.siteName
EventType = $hit.eventType
ProcessName = $hit.srcProcName
CommandLine = $hit.srcProcCmdLine
FilePath = $hit.fileFullName
EventTime = $hit.eventTime
}
}
} else {
Write-Verbose "No hits for: $ioc"
}
}
catch {
Write-S1Log -Message "Query failed for $ioc`: $($_.Exception.Message)" -Level ERROR
}
Start-Sleep -Milliseconds 500 # Rate limiting
}
Write-S1Log -Message "Total IOC hits: $($allResults.Count)" -Level SUCCESS
if ($ExportPath -and $allResults.Count -gt 0) {
$allResults | Export-Csv -Path $ExportPath -NoTypeInformation
Write-S1Log -Message "Results exported to: $ExportPath" -Level SUCCESS
}
# Summary by IOC
if ($allResults.Count -gt 0) {
Write-Host "`nIOC Hit Summary:" -ForegroundColor Cyan
$allResults | Group-Object IOC | ForEach-Object {
Write-Host " $($_.Name): $($_.Count) hits" -ForegroundColor Yellow
}
}
return $allResults
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}5. STAR RULE MANAGEMENT
Export-S1STARRules.ps1 - STAR Rule Export
#Requires -Version 5.1
<#
.SYNOPSIS
Exports all STAR custom detection rules for backup/migration
.DESCRIPTION
Exports STAR rules to JSON for backup, version control, or
deployment to other sites/accounts.
.PARAMETER SiteId
Filter rules by site
.PARAMETER ExportPath
Path for export file
.PARAMETER IncludeDisabled
Include disabled rules in export
.EXAMPLE
.\Export-S1STARRules.ps1 -ExportPath "C:\Backup\star-rules.json"
#>
[CmdletBinding()]
param(
[string]$SiteId,
[string]$ExportPath = "C:\BIN\LOGS\S1-STARRules-$(Get-Date -Format 'yyyyMMdd').json",
[switch]$IncludeDisabled
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Exporting STAR rules" -Level INFO
$queryParams = @{}
if ($SiteId) { $queryParams.siteIds = $SiteId }
$rules = Get-S1AllPages -Endpoint "/web/api/v2.1/cloud-detection/rules" -QueryParams $queryParams
if (-not $IncludeDisabled) {
$rules = $rules | Where-Object { $_.status -eq 'Active' }
}
Write-S1Log -Message "Found $($rules.Count) rules to export" -Level INFO
# Format for export (remove runtime fields)
$exportRules = $rules | ForEach-Object {
[PSCustomObject]@{
name = $_.name
description = $_.description
query = $_.query
severity = $_.severity
status = $_.status
treatAsThreat = $_.treatAsThreat
networkQuarantine = $_.networkQuarantine
siteIds = $_.siteIds
groupIds = $_.groupIds
createdAt = $_.createdAt
updatedAt = $_.updatedAt
}
}
$export = @{
exportDate = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
exportedBy = $env:USERNAME
ruleCount = $exportRules.Count
rules = $exportRules
}
$export | ConvertTo-Json -Depth 10 | Out-File $ExportPath -Encoding UTF8
Write-S1Log -Message "Exported $($rules.Count) rules to: $ExportPath" -Level SUCCESS
# Summary
Write-Host "`nExport Summary:" -ForegroundColor Cyan
$rules | Group-Object severity | ForEach-Object {
Write-Host " $($_.Name): $($_.Count) rules"
}
return $exportRules
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Import-S1STARRules.ps1 - STAR Rule Import
#Requires -Version 5.1
<#
.SYNOPSIS
Imports STAR rules from backup file
.DESCRIPTION
Restores or deploys STAR rules from JSON export.
Can target different sites than original.
.PARAMETER ImportPath
Path to rules JSON file
.PARAMETER TargetSiteId
Site ID to deploy rules to (overrides original)
.PARAMETER ImportAsDisabled
Import rules as disabled for review
.EXAMPLE
.\Import-S1STARRules.ps1 -ImportPath "C:\Backup\star-rules.json" -TargetSiteId "12345"
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory = $true)]
[string]$ImportPath,
[string]$TargetSiteId,
[switch]$ImportAsDisabled,
[switch]$SkipExisting
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
if (-not (Test-Path $ImportPath)) {
throw "Import file not found: $ImportPath"
}
$import = Get-Content $ImportPath -Raw | ConvertFrom-Json
Write-S1Log -Message "Importing $($import.ruleCount) STAR rules" -Level INFO
Write-Host "Export date: $($import.exportDate)"
# Get existing rules to check for duplicates
$existingRules = @()
if ($SkipExisting) {
$existingRules = Get-S1AllPages -Endpoint "/web/api/v2.1/cloud-detection/rules"
$existingNames = $existingRules | Select-Object -ExpandProperty name
}
$results = @()
foreach ($rule in $import.rules) {
# Skip if exists
if ($SkipExisting -and $existingNames -contains $rule.name) {
Write-S1Log -Message "Skipping existing rule: $($rule.name)" -Level INFO
$results += [PSCustomObject]@{
RuleName = $rule.name
Status = 'Skipped'
Reason = 'Already exists'
}
continue
}
if (-not $PSCmdlet.ShouldProcess($rule.name, "Import STAR rule")) {
continue
}
$body = @{
data = @{
name = $rule.name
description = $rule.description
query = $rule.query
queryType = "events"
severity = $rule.severity
status = if ($ImportAsDisabled) { "Disabled" } else { $rule.status }
treatAsThreat = $rule.treatAsThreat
}
}
if ($TargetSiteId) {
$body.data.siteIds = @($TargetSiteId)
} elseif ($rule.siteIds) {
$body.data.siteIds = $rule.siteIds
}
try {
$response = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/cloud-detection/rules" `
-Method POST -Body $body
Write-S1Log -Message "Imported: $($rule.name)" -Level SUCCESS
$results += [PSCustomObject]@{
RuleName = $rule.name
Status = 'Imported'
RuleId = $response.data.id
}
}
catch {
Write-S1Log -Message "Failed to import $($rule.name): $($_.Exception.Message)" -Level ERROR
$results += [PSCustomObject]@{
RuleName = $rule.name
Status = 'Failed'
Reason = $_.Exception.Message
}
}
Start-Sleep -Milliseconds 500
}
# Summary
Write-Host "`nImport Summary:" -ForegroundColor Cyan
$results | Group-Object Status | ForEach-Object {
Write-Host " $($_.Name): $($_.Count)"
}
return $results
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}6. REPORTING AND COMPLIANCE
New-S1ExecutiveReport.ps1 - Executive Summary Generator
#Requires -Version 5.1
<#
.SYNOPSIS
Generates executive summary report for leadership
.DESCRIPTION
Creates HTML report with threat trends, agent coverage,
and key security metrics for executive consumption.
.PARAMETER ReportPeriod
Period for report (daily, weekly, monthly)
.PARAMETER SiteId
Limit to specific site
.PARAMETER OutputPath
Path for HTML report
.EXAMPLE
.\New-S1ExecutiveReport.ps1 -ReportPeriod weekly
#>
[CmdletBinding()]
param(
[ValidateSet('daily', 'weekly', 'monthly')]
[string]$ReportPeriod = 'weekly',
[string]$SiteId,
[string]$OutputPath = "C:\BIN\LOGS\S1-ExecutiveReport-$(Get-Date -Format 'yyyyMMdd').html",
[string]$CompanyName = "Client"
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
$daysBack = switch ($ReportPeriod) {
'daily' { 1 }
'weekly' { 7 }
'monthly' { 30 }
}
$startDate = (Get-Date).AddDays(-$daysBack).ToString("yyyy-MM-ddT00:00:00Z")
Write-S1Log -Message "Generating $ReportPeriod executive report" -Level INFO
# Gather metrics
$queryParams = @{}
if ($SiteId) { $queryParams.siteIds = $SiteId }
# Agent statistics
$agents = Get-S1AllPages -Endpoint "/web/api/v2.1/agents" -QueryParams $queryParams
$totalAgents = $agents.Count
$onlineAgents = ($agents | Where-Object { $_.isActive }).Count
$infectedAgents = ($agents | Where-Object { $_.infected }).Count
$outdatedAgents = ($agents | Where-Object {
[version]$_.agentVersion -lt [version]"23.3.0.0"
}).Count
# Threat statistics
$threatParams = $queryParams.Clone()
$threatParams.createdAt__gte = $startDate
$threats = Get-S1AllPages -Endpoint "/web/api/v2.1/threats" -QueryParams $threatParams
$totalThreats = $threats.Count
$resolvedThreats = ($threats | Where-Object { $_.threatInfo.resolved }).Count
$malwareThreats = ($threats | Where-Object { $_.threatInfo.classification -eq 'malware' }).Count
# Group by classification
$threatsByClass = $threats | Group-Object { $_.threatInfo.classification }
# Calculate trends (compare to previous period)
$prevStartDate = (Get-Date).AddDays(-($daysBack * 2)).ToString("yyyy-MM-ddT00:00:00Z")
$prevEndDate = $startDate
$prevThreatParams = $queryParams.Clone()
$prevThreatParams.createdAt__gte = $prevStartDate
$prevThreatParams.createdAt__lt = $prevEndDate
$prevThreats = Get-S1AllPages -Endpoint "/web/api/v2.1/threats" -QueryParams $prevThreatParams
$threatTrend = if ($prevThreats.Count -gt 0) {
[math]::Round((($totalThreats - $prevThreats.Count) / $prevThreats.Count) * 100, 1)
} else { 0 }
# Generate HTML
$html = @"
<!DOCTYPE html>
\<html\>
\<head\>
\<title\>SentinelOne Executive Report - $CompanyName</title>
\<style\>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f0f2f5; color: #333; }
.header { background: linear-gradient(135deg, #6B2D9E 0%, #4A1F6F 100%); color: white; padding: 30px 40px; }
.header h1 { font-size: 28px; margin-bottom: 5px; }
.header .subtitle { opacity: 0.9; }
.container { max-width: 1200px; margin: 0 auto; padding: 30px; }
.metrics-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 30px; }
.metric-card { background: white; border-radius: 12px; padding: 25px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.metric-value { font-size: 36px; font-weight: bold; color: #6B2D9E; }
.metric-label { color: #666; font-size: 14px; margin-top: 5px; }
.metric-trend { font-size: 12px; margin-top: 8px; }
.trend-up { color: #e74c3c; }
.trend-down { color: #27ae60; }
.section { background: white; border-radius: 12px; padding: 25px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.section h2 { color: #333; margin-bottom: 20px; font-size: 18px; border-bottom: 2px solid #6B2D9E; padding-bottom: 10px; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8f9fa; padding: 12px; text-align: left; font-weight: 600; }
td { padding: 12px; border-bottom: 1px solid #eee; }
.status-healthy { color: #27ae60; }
.status-warning { color: #f39c12; }
.status-critical { color: #e74c3c; }
.progress-bar { background: #e9ecef; border-radius: 10px; height: 20px; overflow: hidden; }
.progress-fill { background: #27ae60; height: 100%; transition: width 0.3s; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
\<body\>
<div class="header">
<h1>SentinelOne Security Report</h1>
<div class="subtitle">$CompanyName | $(Get-Date -Format 'MMMM dd, yyyy') | $($ReportPeriod.ToUpper()) Summary</div>
</div>
<div class="container">
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-value">$totalAgents</div>
<div class="metric-label">Total Endpoints</div>
<div class="metric-trend">$onlineAgents online ($([math]::Round($onlineAgents/$totalAgents*100,0))%)</div>
</div>
<div class="metric-card">
<div class="metric-value">$totalThreats</div>
<div class="metric-label">Threats Detected</div>
<div class="metric-trend $(if($threatTrend -gt 0){'trend-up'}else{'trend-down'})">
$(if($threatTrend -gt 0){"+$threatTrend%"}else{"$threatTrend%"}) vs previous period
</div>
</div>
<div class="metric-card">
<div class="metric-value">$resolvedThreats</div>
<div class="metric-label">Threats Resolved</div>
<div class="metric-trend">$([math]::Round($resolvedThreats/$totalThreats*100,0))% resolution rate</div>
</div>
<div class="metric-card">
<div class="metric-value" style="color: $(if($infectedAgents -gt 0){'#e74c3c'}else{'#27ae60'})">$infectedAgents</div>
<div class="metric-label">Currently Infected</div>
<div class="metric-trend">Endpoints with active threats</div>
</div>
</div>
<div class="section">
<h2>Endpoint Protection Coverage</h2>
<div class="progress-bar">
<div class="progress-fill" style="width: $([math]::Round($onlineAgents/$totalAgents*100,0))%"></div>
</div>
<table style="margin-top: 20px;">
\<tr\>
\<th\>Metric</th>
\<th\>Count</th>
\<th\>Status</th>
</tr>
\<tr\>
\<td\>Online Agents</td>
\<td\>$onlineAgents / $totalAgents</td>
<td class="$(if($onlineAgents/$totalAgents -gt 0.95){'status-healthy'}elseif($onlineAgents/$totalAgents -gt 0.85){'status-warning'}else{'status-critical'})">
$(if($onlineAgents/$totalAgents -gt 0.95){'Healthy'}elseif($onlineAgents/$totalAgents -gt 0.85){'Warning'}else{'Critical'})
</td>
</tr>
\<tr\>
\<td\>Agents Needing Update</td>
\<td\>$outdatedAgents</td>
<td class="$(if($outdatedAgents -eq 0){'status-healthy'}elseif($outdatedAgents -lt 10){'status-warning'}else{'status-critical'})">
$(if($outdatedAgents -eq 0){'Healthy'}elseif($outdatedAgents -lt 10){'Warning'}else{'Needs Attention'})
</td>
</tr>
</table>
</div>
<div class="section">
<h2>Threat Breakdown</h2>
\<table\>
\<tr\>
\<th\>Classification</th>
\<th\>Count</th>
\<th\>Percentage</th>
</tr>
$(foreach ($class in $threatsByClass) {
$pct = [math]::Round($class.Count / $totalThreats * 100, 1)
"\<tr\>\<td\>$($class.Name)</td>\<td\>$($class.Count)</td>\<td\>$pct%</td></tr>"
})
</table>
</div>
</div>
<div class="footer">
Generated by SentinelOne Automation | $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
</div>
</body>
</html>
"@
$html | Out-File $OutputPath -Encoding UTF8
Write-S1Log -Message "Executive report saved to: $OutputPath" -Level SUCCESS
return @{
TotalAgents = $totalAgents
OnlineAgents = $onlineAgents
TotalThreats = $totalThreats
ResolvedThreats = $resolvedThreats
ReportPath = $OutputPath
}
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}7. MSP OPERATIONS SCRIPTS
Get-S1MSPDashboard.ps1 - Multi-Site Health Dashboard
#Requires -Version 5.1
<#
.SYNOPSIS
Generates MSP dashboard data for all client sites
.DESCRIPTION
Aggregates health metrics across all managed sites for
MSP operations dashboard and reporting.
.PARAMETER ExportPath
Path to export dashboard data
.PARAMETER IncludeDetails
Include per-endpoint details
.EXAMPLE
.\Get-S1MSPDashboard.ps1 -ExportPath "C:\Dashboard\msp-data.json"
#>
[CmdletBinding()]
param(
[string]$ExportPath,
[switch]$IncludeDetails
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Generating MSP dashboard data" -Level INFO
# Get all sites
$sites = Get-S1AllPages -Endpoint "/web/api/v2.1/sites"
Write-S1Log -Message "Processing $($sites.Count) client sites" -Level INFO
$dashboardData = @()
foreach ($site in $sites) {
Write-Verbose "Processing site: $($site.name)"
# Get agents for this site
$agents = Get-S1AllPages -Endpoint "/web/api/v2.1/agents" -QueryParams @{
siteIds = $site.id
}
# Get threats for last 24h
$threats = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/threats" -QueryParams @{
siteIds = $site.id
createdAt__gte = (Get-Date).AddDays(-1).ToString("yyyy-MM-ddT00:00:00Z")
limit = 100
}
# Calculate metrics
$totalAgents = $agents.Count
$onlineAgents = ($agents | Where-Object { $_.isActive }).Count
$offlineAgents = $totalAgents - $onlineAgents
$infectedAgents = ($agents | Where-Object { $_.infected }).Count
$outdatedAgents = ($agents | Where-Object {
[version]$_.agentVersion -lt [version]"23.3.0.0"
}).Count
$threatCount24h = $threats.pagination.totalItems
$unresolvedThreats = ($threats.data | Where-Object { -not $_.threatInfo.resolved }).Count
# Determine health status
$healthScore = 100
if ($offlineAgents / [math]::Max($totalAgents, 1) -gt 0.1) { $healthScore -= 20 }
if ($infectedAgents -gt 0) { $healthScore -= 30 }
if ($outdatedAgents / [math]::Max($totalAgents, 1) -gt 0.2) { $healthScore -= 15 }
if ($unresolvedThreats -gt 0) { $healthScore -= 20 }
$healthStatus = switch ($healthScore) {
{ $_ -ge 90 } { 'Healthy' }
{ $_ -ge 70 } { 'Warning' }
default { 'Critical' }
}
$siteData = [PSCustomObject]@{
SiteId = $site.id
SiteName = $site.name
TotalAgents = $totalAgents
OnlineAgents = $onlineAgents
OfflineAgents = $offlineAgents
OnlinePercentage = [math]::Round($onlineAgents / [math]::Max($totalAgents, 1) * 100, 1)
InfectedAgents = $infectedAgents
OutdatedAgents = $outdatedAgents
Threats24h = $threatCount24h
UnresolvedThreats = $unresolvedThreats
HealthScore = $healthScore
HealthStatus = $healthStatus
LicenseUsed = $site.totalLicenses
LastUpdated = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
}
if ($IncludeDetails) {
$siteData | Add-Member -NotePropertyName 'Agents' -NotePropertyValue ($agents | Select-Object computerName, agentVersion, isActive, infected, lastActiveDate)
}
$dashboardData += $siteData
}
# Sort by health status
$dashboardData = $dashboardData | Sort-Object HealthScore
# Summary output
Write-Host "`n=== MSP Dashboard Summary ===" -ForegroundColor Cyan
Write-Host "Total Sites: $($dashboardData.Count)"
Write-Host "Total Agents: $(($dashboardData | Measure-Object -Property TotalAgents -Sum).Sum)"
Write-Host ""
$dashboardData | Group-Object HealthStatus | ForEach-Object {
$color = switch ($_.Name) {
'Healthy' { 'Green' }
'Warning' { 'Yellow' }
'Critical' { 'Red' }
}
Write-Host "$($_.Name): $($_.Count) sites" -ForegroundColor $color
}
# Export if requested
if ($ExportPath) {
$export = @{
generatedAt = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
siteCount = $dashboardData.Count
totalAgents = ($dashboardData | Measure-Object -Property TotalAgents -Sum).Sum
sites = $dashboardData
}
$export | ConvertTo-Json -Depth 5 | Out-File $ExportPath -Encoding UTF8
Write-S1Log -Message "Dashboard data exported to: $ExportPath" -Level SUCCESS
}
return $dashboardData
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Find-S1OrphanedEndpoints.ps1 - Orphaned Endpoint Detection
#Requires -Version 5.1
<#
.SYNOPSIS
Identifies orphaned/stale endpoints across all sites
.DESCRIPTION
Finds endpoints that haven't checked in for extended periods,
helping identify decommissioned systems still consuming licenses.
.PARAMETER DaysOffline
Threshold for considering endpoint orphaned (default: 30)
.PARAMETER ExportPath
Path to export orphaned endpoints
.PARAMETER AutoDecommission
Automatically decommission identified orphans
.EXAMPLE
.\Find-S1OrphanedEndpoints.ps1 -DaysOffline 60 -ExportPath "C:\Reports\orphans.csv"
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[int]$DaysOffline = 30,
[string]$ExportPath,
[switch]$AutoDecommission
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Searching for endpoints offline > $DaysOffline days" -Level INFO
$cutoffDate = (Get-Date).AddDays(-$DaysOffline)
# Get all offline agents
$agents = Get-S1AllPages -Endpoint "/web/api/v2.1/agents" -QueryParams @{
isActive = $false
}
# Filter to orphaned (beyond threshold)
$orphaned = $agents | Where-Object {
[datetime]$_.lastActiveDate -lt $cutoffDate
}
Write-S1Log -Message "Found $($orphaned.Count) orphaned endpoints" -Level WARN
$report = $orphaned | Select-Object @(
@{N='ComputerName'; E={$_.computerName}}
@{N='SiteName'; E={$_.siteName}}
@{N='LastActive'; E={$_.lastActiveDate}}
@{N='DaysOffline'; E={[math]::Round(((Get-Date) - [datetime]$_.lastActiveDate).TotalDays, 0)}}
@{N='AgentVersion'; E={$_.agentVersion}}
@{N='OSName'; E={$_.osName}}
@{N='Domain'; E={$_.domain}}
@{N='AgentId'; E={$_.id}}
) | Sort-Object DaysOffline -Descending
# Group by site
Write-Host "`nOrphaned Endpoints by Site:" -ForegroundColor Cyan
$report | Group-Object SiteName | Sort-Object Count -Descending | ForEach-Object {
Write-Host " $($_.Name): $($_.Count) endpoints"
}
# Export if requested
if ($ExportPath) {
$report | Export-Csv -Path $ExportPath -NoTypeInformation
Write-S1Log -Message "Orphaned endpoints exported to: $ExportPath" -Level SUCCESS
}
# Auto-decommission if requested
if ($AutoDecommission -and $orphaned.Count -gt 0) {
Write-S1Log -Message "Auto-decommissioning $($orphaned.Count) orphaned endpoints" -Level WARN
if ($PSCmdlet.ShouldProcess("$($orphaned.Count) endpoints", "Decommission")) {
$agentIds = $orphaned | Select-Object -ExpandProperty id
# Process in batches
for ($i = 0; $i -lt $agentIds.Count; $i += 100) {
$batch = $agentIds | Select-Object -Skip $i -First 100
$body = @{
filter = @{
ids = $batch
}
}
try {
$response = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents/actions/decommission" `
-Method POST -Body $body
Write-S1Log -Message "Decommissioned batch: $($response.data.affected) agents" -Level SUCCESS
}
catch {
Write-S1Log -Message "Batch decommission failed: $($_.Exception.Message)" -Level ERROR
}
Start-Sleep -Seconds 1
}
}
}
return $report
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}Get-S1LicenseUsage.ps1 - License Usage Tracking
#Requires -Version 5.1
<#
.SYNOPSIS
Tracks license usage across all sites
.DESCRIPTION
Reports on license allocation and usage per site to help
with capacity planning and billing.
.PARAMETER ExportPath
Path to export license report
.EXAMPLE
.\Get-S1LicenseUsage.ps1 -ExportPath "C:\Reports\licenses.csv"
#>
[CmdletBinding()]
param(
[string]$ExportPath
)
$ErrorActionPreference = 'Stop'
Import-Module "C:\Scripts\SentinelOne-API-Module.psm1" -Force
try {
Connect-S1Console -UseStoredCredentials
Write-S1Log -Message "Gathering license usage data" -Level INFO
# Get account info
$account = Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/accounts"
# Get all sites with license info
$sites = Get-S1AllPages -Endpoint "/web/api/v2.1/sites"
$licenseReport = foreach ($site in $sites) {
# Count actual agents
$agentCount = (Invoke-S1ApiRequest -Endpoint "/web/api/v2.1/agents" -QueryParams @{
siteIds = $site.id
countOnly = $true
}).pagination.totalItems
[PSCustomObject]@{
SiteName = $site.name
SiteId = $site.id
LicensesAllocated = $site.totalLicenses
LicensesUsed = $agentCount
LicensesAvailable = $site.totalLicenses - $agentCount
UtilizationPercent = [math]::Round($agentCount / [math]::Max($site.totalLicenses, 1) * 100, 1)
SiteState = $site.state
Expiration = $site.expiration
}
}
# Summary
$totalAllocated = ($licenseReport | Measure-Object -Property LicensesAllocated -Sum).Sum
$totalUsed = ($licenseReport | Measure-Object -Property LicensesUsed -Sum).Sum
Write-Host "`n=== License Usage Summary ===" -ForegroundColor Cyan
Write-Host "Total Licenses Allocated: $totalAllocated"
Write-Host "Total Licenses Used: $totalUsed"
Write-Host "Overall Utilization: $([math]::Round($totalUsed / [math]::Max($totalAllocated, 1) * 100, 1))%"
Write-Host ""
# Sites over 90% utilization
$highUtil = $licenseReport | Where-Object { $_.UtilizationPercent -gt 90 }
if ($highUtil) {
Write-Host "Sites at >90% capacity:" -ForegroundColor Yellow
$highUtil | ForEach-Object {
Write-Host " $($_.SiteName): $($_.UtilizationPercent)%"
}
}
# Export if requested
if ($ExportPath) {
$licenseReport | Export-Csv -Path $ExportPath -NoTypeInformation
Write-S1Log -Message "License report exported to: $ExportPath" -Level SUCCESS
}
return $licenseReport
} catch {
Write-S1Log -Message "Error: $($_.Exception.Message)" -Level ERROR
throw
} finally {
Disconnect-S1Console
}SCHEDULED TASK SETUP
Create-S1ScheduledTasks.ps1 - Automation Task Setup
#Requires -Version 5.1
<#
.SYNOPSIS
Creates scheduled tasks for SentinelOne automation
.DESCRIPTION
Sets up Windows Task Scheduler jobs for daily operations,
threat hunting, and reporting.
.PARAMETER ScriptsPath
Path to SentinelOne scripts
.PARAMETER LogPath
Path for script logs
.EXAMPLE
.\Create-S1ScheduledTasks.ps1 -ScriptsPath "C:\Scripts\SentinelOne"
#>
[CmdletBinding()]
param(
[string]$ScriptsPath = "C:\Scripts\SentinelOne",
[string]$LogPath = "C:\BIN\LOGS"
)
$ErrorActionPreference = 'Stop'
$tasks = @(
@{
Name = "S1-DailyHealthCheck"
Script = "Get-S1AgentHealth.ps1"
Schedule = "06:00"
Description = "Daily agent health check with email alerts"
},
@{
Name = "S1-ThreatHunting"
Script = "Invoke-S1ThreatHunt.ps1 -HuntName All -AlertOnFindings"
Schedule = "08:00"
Description = "Daily threat hunting sweep"
},
@{
Name = "S1-WeeklyReport"
Script = "New-S1ExecutiveReport.ps1 -ReportPeriod weekly"
Schedule = "08:00"
Frequency = "Weekly"
DayOfWeek = "Monday"
Description = "Weekly executive security report"
},
@{
Name = "S1-OrphanDetection"
Script = "Find-S1OrphanedEndpoints.ps1 -DaysOffline 30"
Schedule = "02:00"
Frequency = "Weekly"
DayOfWeek = "Sunday"
Description = "Weekly orphaned endpoint detection"
},
@{
Name = "S1-LicenseReport"
Script = "Get-S1LicenseUsage.ps1"
Schedule = "07:00"
Frequency = "Monthly"
Description = "Monthly license usage report"
}
)
foreach ($task in $tasks) {
Write-Host "Creating task: $($task.Name)" -ForegroundColor Cyan
$scriptPath = Join-Path $ScriptsPath $task.Script.Split(' ')[0]
$arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
if ($task.Script -match ' (.+)$') {
$arguments += " $($Matches[1])"
}
$action = New-ScheduledTaskAction -Execute "powershell.exe" -Argument $arguments
$trigger = switch ($task.Frequency) {
'Weekly' {
New-ScheduledTaskTrigger -Weekly -DaysOfWeek $task.DayOfWeek -At $task.Schedule
}
'Monthly' {
New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -WeeksInterval 4 -At $task.Schedule
}
default {
New-ScheduledTaskTrigger -Daily -At $task.Schedule
}
}
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Hours 2) `
-RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5)
try {
Register-ScheduledTask -TaskName $task.Name -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Description $task.Description -Force
Write-Host " [OK] Created: $($task.Name)" -ForegroundColor Green
}
catch {
Write-Host " [FAIL] $($_.Exception.Message)" -ForegroundColor Red
}
}
Write-Host "`nScheduled tasks created successfully!" -ForegroundColor Green
Write-Host "View tasks in Task Scheduler under 'S1-*'" -ForegroundColor CyanRELATED DOCUMENTATION
- HOWTO- SentinelOne PowerShell API Automation
- HOWTO- SentinelOne Deep Visibility Threat Hunting
- HOWTO- SentinelOne STAR Custom Detection Rules
- HOWTO- SentinelOne MSP Client Onboarding
SOURCES
REVISION HISTORY
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2026-01-08 | CosmicBytez | Initial creation |