1309 lines
57 KiB
PowerShell
1309 lines
57 KiB
PowerShell
#requires -Version 5.1
|
|
<#
|
|
______ __ __ __
|
|
/ ____/_ __/ /_ ___ _____/ /____ / /__
|
|
/ / / / / / __ \/ _ \/ ___/ __/ _ \/ //_/
|
|
/ /___/ /_/ / /_/ / __/ / / /_/ __/ ,<
|
|
\____/\__, /_.___/\___/_/ \__/\___/_/|_|
|
|
/____/
|
|
|
|
.SYNOPSIS
|
|
Syncro detector for unauthorized Living-off-the-Land RMM tools.
|
|
|
|
.DESCRIPTION
|
|
Pulls the LOLRMM catalog, inspects the endpoint for unauthorized RMM
|
|
artifacts, and raises a Syncro alert on findings. The current production
|
|
scope is limited to LOLRMM-backed RMM identification through:
|
|
|
|
1. Running processes
|
|
2. Installed services
|
|
3. Live network sockets
|
|
4. Scheduled tasks
|
|
5. Registry artifacts
|
|
|
|
Matching is intentionally conservative to reduce false positives and keep
|
|
output tied to explicit LOLRMM tool matches.
|
|
|
|
IMPORTANT:
|
|
- Network detection is point-in-time only, not historical flow logging.
|
|
- Reverse DNS is disabled by default to avoid endpoint-specific timeouts.
|
|
- LOLRMM data is community-driven; some tools have richer metadata than others.
|
|
- Test on internal systems before broad deployment.
|
|
|
|
.NOTES
|
|
Script Name : lolrmm_syncro_detector.ps1
|
|
Author : Cybertek
|
|
Maintainer : Cybertek / Codex-assisted cleanup
|
|
Version : 2026-03-14
|
|
Run Context : SYSTEM via Syncro RMM
|
|
Purpose : Detect non-approved LOLRMM catalog tools with low noise.
|
|
Notes : This script is the detection baseline. Future MIT/public
|
|
variants may branch from this file for remediation or
|
|
user-interactive workflows.
|
|
#>
|
|
|
|
Set-StrictMode -Version Latest
|
|
$ErrorActionPreference = 'Stop'
|
|
|
|
# Import the Syncro Module to enable Rmm-Alert functionality
|
|
Import-Module $env:SyncroModule
|
|
|
|
# --- Syncro Script Variables ---------------------------------------------------
|
|
# The allowlist dimensions below can be configured directly in Syncro's script
|
|
# editor under Script Variables, removing the need to edit this script per customer.
|
|
#
|
|
# Create these Script Variables in Syncro (all optional; defaults apply if blank):
|
|
#
|
|
# Variable Name Example Value
|
|
# ------------------------- ------------------------------------------------
|
|
# approved_domains splashtop.com,syncromsp.com
|
|
# approved_process_names splashtopremote,splashtopservice
|
|
# approved_path_patterns *\Splashtop*,*\Syncro*
|
|
# approved_publishers Splashtop Inc.,Servably, Inc.
|
|
#
|
|
# Values are comma-separated strings. If a variable is blank or not defined in
|
|
# Syncro, the defaults hardcoded in $Config below are used as a fallback.
|
|
# -------------------------------------------------------------------------------
|
|
|
|
function Resolve-AllowlistVar {
|
|
# Reads a Syncro script variable by name and splits it into a string array.
|
|
# Falls back to $Defaults if the variable is not defined or is blank.
|
|
# Uses Get-Variable to avoid strict-mode errors on undefined variables.
|
|
param(
|
|
[Parameter(Mandatory)] [string]$VariableName,
|
|
[Parameter(Mandatory)] [string[]]$Defaults
|
|
)
|
|
$var = Get-Variable -Name $VariableName -ErrorAction SilentlyContinue
|
|
if ($null -eq $var -or [string]::IsNullOrWhiteSpace([string]$var.Value)) {
|
|
return $Defaults
|
|
}
|
|
return @(([string]$var.Value) -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
|
|
}
|
|
|
|
# --- Configuration -------------------------------------------------------------
|
|
$Config = [ordered]@{
|
|
LolrmmApiUrl = 'https://lolrmm.io/api/rmm_tools.json'
|
|
CachePath = 'C:\ProgramData\Cybertek\LOLRMM\rmm_tools_cache.json'
|
|
CacheHashPath = 'C:\ProgramData\Cybertek\LOLRMM\rmm_tools_cache.json.sha256'
|
|
StatePath = 'C:\ProgramData\Cybertek\LOLRMM\last_detections.json'
|
|
ReportPath = 'C:\ProgramData\Cybertek\LOLRMM\last_scan_report.txt'
|
|
CacheMaxAgeHours = 48
|
|
MinFeedEntryCount = 50
|
|
HardenCacheDirectoryAcl = $true
|
|
AlertCategory = 'unauthorized_rmm_detected'
|
|
LogEventName = 'Unauthorized RMM Detection'
|
|
CloseAlertWhenClean = $true
|
|
SetAssetFields = $false
|
|
AssetFieldLastScan = 'LOLRMM Last Scan'
|
|
AssetFieldLastResult = 'LOLRMM Last Result'
|
|
AssetFieldLastDetections = 'LOLRMM Last Detections'
|
|
# Reverse DNS can hang badly on some endpoints and is not required for core RMM
|
|
# identification, so keep it off by default.
|
|
ResolveReverseDns = $false
|
|
ResolveReverseDnsPrivateIPs = $false
|
|
MaxConnectionsToInspect = 200
|
|
MaxNetworkMatchesInAlert = 15
|
|
MaxIndicatorItemsPerType = 5000
|
|
MaxAlertBodyLength = 7000
|
|
|
|
# Feature flags
|
|
EnableScheduledTaskDetection = $true
|
|
EnableRegistryArtifacts = $true
|
|
EnablePEVersionCheck = $true
|
|
SuppressRecurringDetections = $false
|
|
|
|
# Allowlist - configurable via Syncro Script Variables (see header above).
|
|
# Defaults are used when the corresponding Syncro variable is blank or absent.
|
|
ApprovedDomains = Resolve-AllowlistVar -VariableName 'approved_domains' -Defaults @(
|
|
'splashtop.com',
|
|
'syncromsp.com'
|
|
)
|
|
ApprovedProcessNames = Resolve-AllowlistVar -VariableName 'approved_process_names' -Defaults @(
|
|
'splashtop',
|
|
'splashtopremote',
|
|
'splashtop streamer',
|
|
'splashtop_streamer',
|
|
'splashtopservice',
|
|
'splashtop-streamer',
|
|
'syncro'
|
|
)
|
|
ApprovedPathPatterns = Resolve-AllowlistVar -VariableName 'approved_path_patterns' -Defaults @(
|
|
'*\Splashtop*',
|
|
'*\Syncro*'
|
|
)
|
|
ApprovedPublishers = Resolve-AllowlistVar -VariableName 'approved_publishers' -Defaults @(
|
|
'Splashtop Inc.',
|
|
'Servably, Inc.',
|
|
'Microsoft Windows',
|
|
'Microsoft Corporation',
|
|
'Microsoft Windows Hardware Compatibility Publisher'
|
|
)
|
|
|
|
# Excluded from process detection (browsers, core OS)
|
|
ExcludedProcessNames = @(
|
|
'chrome', 'msedge', 'firefox', 'iexplore', 'opera', 'brave',
|
|
'svchost', 'system', 'idle', 'lsass', 'services', 'wininit', 'winlogon'
|
|
)
|
|
|
|
# Scheduled task paths to skip (Microsoft built-ins)
|
|
ExcludedTaskPaths = @(
|
|
'\Microsoft\Windows\',
|
|
'\Microsoft\Office\',
|
|
'\Microsoft\VisualStudio\'
|
|
)
|
|
}
|
|
|
|
# --- Utility functions ---------------------------------------------------------
|
|
|
|
function Write-Log {
|
|
param([string]$Message)
|
|
[Console]::WriteLine("[LOLRMM] $Message")
|
|
}
|
|
|
|
function Ensure-ParentDirectory {
|
|
param([string]$Path)
|
|
$parent = Split-Path -Path $Path -Parent
|
|
if (-not [string]::IsNullOrWhiteSpace($parent) -and -not (Test-Path -LiteralPath $parent)) {
|
|
New-Item -Path $parent -ItemType Directory -Force | Out-Null
|
|
}
|
|
}
|
|
|
|
function Set-CacheDirectoryAcl {
|
|
param([string]$DirectoryPath)
|
|
# Restrict cache directory to SYSTEM and Administrators only.
|
|
# Prevents a low-privilege process from tampering with the cached feed.
|
|
try {
|
|
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
$acl.SetAccessRuleProtection($true, $false) # Disable inheritance, do not preserve inherited rules
|
|
$systemSid = New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-18'
|
|
$adminsSid = New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544'
|
|
|
|
foreach ($sid in @($systemSid, $adminsSid)) {
|
|
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
|
|
$sid,
|
|
[System.Security.AccessControl.FileSystemRights]::FullControl,
|
|
[System.Security.AccessControl.InheritanceFlags]'ContainerInherit,ObjectInherit',
|
|
[System.Security.AccessControl.PropagationFlags]::None,
|
|
[System.Security.AccessControl.AccessControlType]::Allow
|
|
)
|
|
$acl.AddAccessRule($rule)
|
|
}
|
|
|
|
Set-Acl -LiteralPath $DirectoryPath -AclObject $acl -ErrorAction Stop
|
|
Write-Log "Cache directory ACL hardened: $DirectoryPath"
|
|
}
|
|
catch {
|
|
Write-Warning "Could not harden cache directory ACL: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
function Get-StringHash {
|
|
param([Parameter(Mandatory)] [string]$InputString)
|
|
$bytes = [System.Text.Encoding]::UTF8.GetBytes($InputString)
|
|
$sha = [System.Security.Cryptography.SHA256]::Create()
|
|
try { return ([System.BitConverter]::ToString($sha.ComputeHash($bytes))) -replace '-', '' }
|
|
finally { $sha.Dispose() }
|
|
}
|
|
|
|
function Get-JsonFromUrlOrCache {
|
|
param(
|
|
[Parameter(Mandatory)] [string]$Url,
|
|
[Parameter(Mandatory)] [string]$CachePath,
|
|
[Parameter(Mandatory)] [string]$CacheHashPath,
|
|
[Parameter(Mandatory)] [int]$CacheMaxAgeHours,
|
|
[Parameter(Mandatory)] [int]$MinFeedEntryCount
|
|
)
|
|
|
|
$jsonText = $null
|
|
$source = $null
|
|
|
|
try {
|
|
Write-Log "Downloading LOLRMM feed from $Url"
|
|
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop
|
|
$jsonText = $response.Content
|
|
|
|
# Validate before caching - reject empty or suspiciously small feeds
|
|
$parsed = $jsonText | ConvertFrom-Json
|
|
$entryCount = @($parsed).Count
|
|
if ($entryCount -lt $MinFeedEntryCount) {
|
|
throw "Feed validation failed: only $entryCount entries (minimum $MinFeedEntryCount expected)."
|
|
}
|
|
|
|
# Cache feed and its SHA-256 hash together (best-effort; may fail if not running as SYSTEM/Admin)
|
|
try {
|
|
Ensure-ParentDirectory -Path $CachePath
|
|
Set-Content -LiteralPath $CachePath -Value $jsonText -Encoding UTF8 -Force
|
|
Set-Content -LiteralPath $CacheHashPath -Value (Get-StringHash -InputString $jsonText) -Encoding ASCII -Force
|
|
}
|
|
catch {
|
|
Write-Warning "Could not write LOLRMM feed cache: $($_.Exception.Message)"
|
|
}
|
|
$source = 'live'
|
|
}
|
|
catch {
|
|
Write-Warning "Live LOLRMM download/validation failed: $($_.Exception.Message)"
|
|
|
|
try {
|
|
if (Test-Path -LiteralPath $CachePath -ErrorAction Stop) {
|
|
$cacheAgeHours = ((Get-Date) - (Get-Item -LiteralPath $CachePath -ErrorAction Stop).LastWriteTime).TotalHours
|
|
if ($cacheAgeHours -le $CacheMaxAgeHours) {
|
|
$cachedContent = Get-Content -LiteralPath $CachePath -Raw -ErrorAction Stop
|
|
|
|
# Verify cache integrity before trusting it
|
|
if (Test-Path -LiteralPath $CacheHashPath -ErrorAction SilentlyContinue) {
|
|
$expectedHash = (Get-Content -LiteralPath $CacheHashPath -Raw -ErrorAction SilentlyContinue).Trim()
|
|
$actualHash = Get-StringHash -InputString $cachedContent
|
|
if ($actualHash -ne $expectedHash) {
|
|
throw "Cache integrity check FAILED (hash mismatch). Cache may have been tampered with. Aborting."
|
|
}
|
|
Write-Log "Cache integrity verified."
|
|
}
|
|
else {
|
|
Write-Warning "No cache hash file found; skipping integrity check."
|
|
}
|
|
|
|
Write-Log "Using cached LOLRMM feed (age: $([math]::Round($cacheAgeHours, 2)) hours)"
|
|
$jsonText = $cachedContent
|
|
$source = 'cache'
|
|
}
|
|
else {
|
|
Write-Warning "Cached LOLRMM feed is too old ($([math]::Round($cacheAgeHours, 2)) hours)."
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
Write-Warning "Could not read LOLRMM cache: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
if (-not $jsonText) {
|
|
throw "Unable to obtain LOLRMM feed from live source or usable cache."
|
|
}
|
|
|
|
return [PSCustomObject]@{
|
|
Source = $source
|
|
Data = ($jsonText | ConvertFrom-Json)
|
|
}
|
|
}
|
|
|
|
function Get-PropSafe {
|
|
# Safely retrieves a property value from any object under Set-StrictMode.
|
|
# Returns $null if the property does not exist rather than throwing.
|
|
param(
|
|
[AllowNull()] [object]$Object,
|
|
[Parameter(Mandatory)] [string]$Name
|
|
)
|
|
if ($null -eq $Object) { return $null }
|
|
$prop = $Object.PSObject.Properties[$Name]
|
|
if ($null -eq $prop) { return $null }
|
|
return $prop.Value
|
|
}
|
|
|
|
function ConvertTo-NormalizedString {
|
|
param([AllowNull()] [object]$Value)
|
|
if ($null -eq $Value) { return $null }
|
|
$s = [string]$Value
|
|
if ([string]::IsNullOrWhiteSpace($s)) { return $null }
|
|
return $s.Trim().ToLowerInvariant()
|
|
}
|
|
|
|
function Add-Indicator {
|
|
param(
|
|
[Parameter(Mandatory)] [hashtable]$Set,
|
|
[AllowNull()] [string]$Value,
|
|
[int]$MaxItems = 5000
|
|
)
|
|
$normalized = ConvertTo-NormalizedString $Value
|
|
if (-not $normalized) { return }
|
|
if ($Set.Count -ge $MaxItems) { return }
|
|
$Set[$normalized] = $true
|
|
}
|
|
|
|
function Get-SafeCount {
|
|
param([AllowNull()] [object]$Value)
|
|
if ($null -eq $Value) { return 0 }
|
|
|
|
$countProp = $Value.PSObject.Properties['Count']
|
|
if ($countProp) {
|
|
try { return [int]$countProp.Value } catch {}
|
|
}
|
|
|
|
if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) {
|
|
return @($Value).Count
|
|
}
|
|
|
|
return 1
|
|
}
|
|
|
|
function Add-IndicatorMapEntry {
|
|
param(
|
|
[Parameter(Mandatory)] [hashtable]$Map,
|
|
[AllowNull()] [string]$Indicator,
|
|
[AllowNull()] [string]$ToolName
|
|
)
|
|
$normalizedIndicator = ConvertTo-NormalizedString $Indicator
|
|
$normalizedToolName = ConvertTo-NormalizedString $ToolName
|
|
if (-not $normalizedIndicator -or -not $normalizedToolName) { return }
|
|
|
|
if (-not $Map.ContainsKey($normalizedIndicator)) {
|
|
$Map[$normalizedIndicator] = New-Object System.Collections.Generic.HashSet[string]
|
|
}
|
|
[void]$Map[$normalizedIndicator].Add($normalizedToolName)
|
|
}
|
|
|
|
function Add-PathLeafIndicators {
|
|
param(
|
|
[Parameter(Mandatory)] [hashtable]$Set,
|
|
[AllowNull()] [string]$PathValue,
|
|
[int]$MaxItems = 5000
|
|
)
|
|
$normalizedPath = ConvertTo-NormalizedString $PathValue
|
|
if (-not $normalizedPath) { return }
|
|
|
|
Add-Indicator -Set $Set -Value $normalizedPath -MaxItems $MaxItems
|
|
|
|
$leaf = $normalizedPath
|
|
if ($normalizedPath -match '[\\/]') {
|
|
try { $leaf = Split-Path -Path $normalizedPath -Leaf } catch {}
|
|
}
|
|
if ($leaf) {
|
|
Add-Indicator -Set $Set -Value $leaf -MaxItems $MaxItems
|
|
$leafNoExt = [System.IO.Path]::GetFileNameWithoutExtension($leaf)
|
|
if ($leafNoExt) {
|
|
Add-Indicator -Set $Set -Value $leafNoExt -MaxItems $MaxItems
|
|
}
|
|
}
|
|
}
|
|
|
|
function Get-LolrmmIndicators {
|
|
param(
|
|
[Parameter(Mandatory)] [object[]]$Catalog,
|
|
[Parameter(Mandatory)] [hashtable]$Config
|
|
)
|
|
|
|
$processNames = @{}
|
|
$peNames = @{}
|
|
$pathPatterns = @{}
|
|
$domains = @{}
|
|
$processNameMap = @{}
|
|
$peNameMap = @{}
|
|
$pathMap = @{}
|
|
$domainMap = @{}
|
|
$registryPaths = [System.Collections.Generic.List[PSCustomObject]]::new()
|
|
|
|
foreach ($tool in $Catalog) {
|
|
$toolName = if ($tool.PSObject.Properties['Name']) { [string]$tool.Name } else { $null }
|
|
|
|
# LOLRMM catalog entries have variable structure — every property access on a
|
|
# deserialized JSON object must be guarded with PSObject.Properties to avoid
|
|
# strict-mode PropertyNotFoundException on absent fields.
|
|
|
|
$details = if ($tool.PSObject.Properties['Details']) { $tool.Details } else { $null }
|
|
if ($details) {
|
|
$peMetaRaw = if ($details.PSObject.Properties['PEMetadata']) { $details.PEMetadata } else { $null }
|
|
$peMeta = @()
|
|
if ($peMetaRaw -is [System.Collections.IEnumerable] -and -not ($peMetaRaw -is [string])) {
|
|
$peMeta = @($peMetaRaw)
|
|
}
|
|
elseif ($peMetaRaw) {
|
|
$peMeta = @($peMetaRaw)
|
|
}
|
|
|
|
foreach ($pe in $peMeta) {
|
|
foreach ($field in @('Filename', 'OriginalFileName', 'InternalName')) {
|
|
if ($pe.PSObject.Properties[$field]) {
|
|
$value = [string]$pe.$field
|
|
Add-PathLeafIndicators -Set $processNames -PathValue $value -MaxItems $Config.MaxIndicatorItemsPerType
|
|
Add-IndicatorMapEntry -Map $processNameMap -Indicator $value -ToolName $toolName
|
|
$normalizedValue = ConvertTo-NormalizedString $value
|
|
if ($normalizedValue -match '[\\/]') {
|
|
try { $leaf = Split-Path -Path $normalizedValue -Leaf } catch { $leaf = $null }
|
|
if ($leaf) {
|
|
Add-IndicatorMapEntry -Map $processNameMap -Indicator $leaf -ToolName $toolName
|
|
$leafNoExt = [System.IO.Path]::GetFileNameWithoutExtension($leaf)
|
|
if ($leafNoExt) {
|
|
Add-IndicatorMapEntry -Map $processNameMap -Indicator $leafNoExt -ToolName $toolName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($field in @('Product', 'Description')) {
|
|
if ($pe.PSObject.Properties[$field]) {
|
|
$value = [string]$pe.$field
|
|
Add-Indicator -Set $peNames -Value $value -MaxItems $Config.MaxIndicatorItemsPerType
|
|
Add-IndicatorMapEntry -Map $peNameMap -Indicator $value -ToolName $toolName
|
|
}
|
|
}
|
|
}
|
|
|
|
$installPaths = if ($details.PSObject.Properties['InstallationPaths']) { $details.InstallationPaths } else { $null }
|
|
if ($installPaths) {
|
|
foreach ($path in @($installPaths)) {
|
|
$pathString = [string]$path
|
|
Add-Indicator -Set $pathPatterns -Value $pathString -MaxItems $Config.MaxIndicatorItemsPerType
|
|
Add-IndicatorMapEntry -Map $pathMap -Indicator $pathString -ToolName $toolName
|
|
}
|
|
}
|
|
}
|
|
|
|
$artifacts = if ($tool.PSObject.Properties['Artifacts']) { $tool.Artifacts } else { $null }
|
|
if ($artifacts) {
|
|
$networkArtifacts = if ($artifacts.PSObject.Properties['Network']) { $artifacts.Network } else { $null }
|
|
if ($networkArtifacts) {
|
|
foreach ($net in @($networkArtifacts)) {
|
|
$netDomains = if ($net.PSObject.Properties['Domains']) { $net.Domains } else { $null }
|
|
if ($netDomains) {
|
|
foreach ($domain in @($netDomains)) {
|
|
$d = ConvertTo-NormalizedString $domain
|
|
if ($d) {
|
|
$d = $d -replace '^\*\.', ''
|
|
$d = $d -replace '^\*', ''
|
|
$d = $d.Trim('.')
|
|
Add-Indicator -Set $domains -Value $d -MaxItems $Config.MaxIndicatorItemsPerType
|
|
Add-IndicatorMapEntry -Map $domainMap -Indicator $d -ToolName $toolName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($Config.EnableRegistryArtifacts) {
|
|
$registryArtifacts = if ($artifacts.PSObject.Properties['Registry']) { $artifacts.Registry } else { $null }
|
|
if ($registryArtifacts) {
|
|
foreach ($regArtifact in @($registryArtifacts)) {
|
|
$regPath = $null
|
|
if ($regArtifact -is [string]) {
|
|
$regPath = $regArtifact
|
|
}
|
|
elseif ($regArtifact.PSObject.Properties['Path']) {
|
|
$regPath = [string]$regArtifact.Path
|
|
}
|
|
|
|
if (-not [string]::IsNullOrWhiteSpace($regPath)) {
|
|
$toolName = if ($tool.PSObject.Properties['Name']) { $tool.Name } else { 'Unknown' }
|
|
$regValue = if ($regArtifact.PSObject.Properties['Value']) { [string]$regArtifact.Value } else { $null }
|
|
$registryPaths.Add([PSCustomObject]@{
|
|
ToolName = $toolName
|
|
RegPath = $regPath
|
|
Value = $regValue
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return [PSCustomObject]@{
|
|
ProcessNames = [string[]]$processNames.Keys
|
|
PENames = [string[]]$peNames.Keys
|
|
PathPatterns = [string[]]$pathPatterns.Keys
|
|
Domains = [string[]]$domains.Keys
|
|
ProcessNameMap = $processNameMap
|
|
PENameMap = $peNameMap
|
|
PathMap = $pathMap
|
|
DomainMap = $domainMap
|
|
RegistryPaths = $registryPaths
|
|
}
|
|
}
|
|
|
|
# --- Detection helpers ---------------------------------------------------------
|
|
|
|
function Test-IsPrivateIPv4 {
|
|
param([string]$IpAddress)
|
|
if (-not $IpAddress) { return $false }
|
|
return (
|
|
$IpAddress -like '10.*' -or
|
|
$IpAddress -like '192.168.*' -or
|
|
$IpAddress -match '^172\.(1[6-9]|2[0-9]|3[0-1])\.' -or
|
|
$IpAddress -eq '127.0.0.1'
|
|
)
|
|
}
|
|
|
|
function Resolve-Hostname {
|
|
param([string]$IpAddress)
|
|
try { return ([System.Net.Dns]::GetHostEntry($IpAddress)).HostName }
|
|
catch { return $null }
|
|
}
|
|
|
|
function Test-MatchesAnyPattern {
|
|
param(
|
|
[AllowNull()] [string]$Value,
|
|
[string[]]$Patterns
|
|
)
|
|
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
|
$v = $Value.ToLowerInvariant()
|
|
foreach ($pattern in $Patterns) {
|
|
if ([string]::IsNullOrWhiteSpace($pattern)) { continue }
|
|
if ($v -like $pattern.ToLowerInvariant()) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-MatchesAnySubstring {
|
|
param(
|
|
[AllowNull()] [string]$Value,
|
|
[string[]]$Indicators
|
|
)
|
|
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
|
$v = $Value.ToLowerInvariant()
|
|
foreach ($i in $Indicators) {
|
|
if ([string]::IsNullOrWhiteSpace($i)) { continue }
|
|
if ($v.Contains($i.ToLowerInvariant())) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-MatchesAnyExact {
|
|
# Exact case-insensitive equality match. Used for process/service names to avoid
|
|
# false positives from short indicator strings being substrings of unrelated names.
|
|
param(
|
|
[AllowNull()] [string]$Value,
|
|
[string[]]$Indicators
|
|
)
|
|
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
|
$v = $Value.ToLowerInvariant()
|
|
foreach ($i in $Indicators) {
|
|
if ([string]::IsNullOrWhiteSpace($i)) { continue }
|
|
if ($v -eq $i.ToLowerInvariant()) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-MatchesAnyFullPath {
|
|
# Substring match ONLY for indicators that contain a path separator.
|
|
# Prevents short filename stems from spuriously matching unrelated paths.
|
|
param(
|
|
[AllowNull()] [string]$Value,
|
|
[string[]]$Indicators
|
|
)
|
|
if ([string]::IsNullOrWhiteSpace($Value)) { return $false }
|
|
$v = $Value.ToLowerInvariant()
|
|
foreach ($i in $Indicators) {
|
|
if ([string]::IsNullOrWhiteSpace($i)) { continue }
|
|
$il = $i.ToLowerInvariant()
|
|
if (($il.Contains('\') -or $il.Contains('/')) -and $v.Contains($il)) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Test-DomainMatch {
|
|
param(
|
|
[AllowNull()] [string]$Hostname,
|
|
[string[]]$Domains
|
|
)
|
|
if ([string]::IsNullOrWhiteSpace($Hostname)) { return $false }
|
|
$h = $Hostname.ToLowerInvariant().Trim('.')
|
|
foreach ($domain in $Domains) {
|
|
if ([string]::IsNullOrWhiteSpace($domain)) { continue }
|
|
$d = $domain.ToLowerInvariant().Trim('.')
|
|
if ($h -eq $d -or $h.EndsWith('.' + $d)) { return $true }
|
|
}
|
|
return $false
|
|
}
|
|
|
|
function Get-MappedToolsExact {
|
|
param(
|
|
[AllowNull()] [string]$Value,
|
|
[Parameter(Mandatory)] [hashtable]$Map
|
|
)
|
|
$normalized = ConvertTo-NormalizedString $Value
|
|
if (-not $normalized -or -not $Map.ContainsKey($normalized)) { return @() }
|
|
return [string[]]($Map[$normalized] | Sort-Object -Unique)
|
|
}
|
|
|
|
function Get-MappedToolsForPath {
|
|
param(
|
|
[AllowNull()] [string]$Value,
|
|
[Parameter(Mandatory)] [hashtable]$Map
|
|
)
|
|
$normalized = ConvertTo-NormalizedString $Value
|
|
if (-not $normalized) { return @() }
|
|
|
|
$tools = New-Object System.Collections.Generic.HashSet[string]
|
|
foreach ($indicator in $Map.Keys) {
|
|
$il = [string]$indicator
|
|
if (($il.Contains('\') -or $il.Contains('/')) -and $normalized.Contains($il)) {
|
|
foreach ($tool in $Map[$indicator]) { [void]$tools.Add($tool) }
|
|
}
|
|
}
|
|
return [string[]]($tools | Sort-Object)
|
|
}
|
|
|
|
function Get-MappedToolsForDomain {
|
|
param(
|
|
[AllowNull()] [string]$Hostname,
|
|
[Parameter(Mandatory)] [hashtable]$Map
|
|
)
|
|
$normalized = ConvertTo-NormalizedString $Hostname
|
|
if (-not $normalized) { return @() }
|
|
|
|
$tools = New-Object System.Collections.Generic.HashSet[string]
|
|
foreach ($domain in $Map.Keys) {
|
|
$d = [string]$domain
|
|
if ($normalized -eq $d -or $normalized.EndsWith('.' + $d)) {
|
|
foreach ($tool in $Map[$domain]) { [void]$tools.Add($tool) }
|
|
}
|
|
}
|
|
return [string[]]($tools | Sort-Object)
|
|
}
|
|
|
|
function Get-ProcessPublisher {
|
|
param([AllowNull()] [string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path) -or
|
|
-not (Test-Path -LiteralPath $Path -PathType Leaf -ErrorAction SilentlyContinue)) {
|
|
return $null
|
|
}
|
|
try {
|
|
$sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop
|
|
if ($sig.SignerCertificate -and $sig.SignerCertificate.Subject) {
|
|
return $sig.SignerCertificate.Subject
|
|
}
|
|
}
|
|
catch {}
|
|
return $null
|
|
}
|
|
|
|
function Get-PEVersionInfo {
|
|
# Reads the PE version resource block - survives binary renaming.
|
|
# ProductName, FileDescription, CompanyName are set by the developer and baked in.
|
|
param([AllowNull()] [string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path) -or
|
|
-not (Test-Path -LiteralPath $Path -PathType Leaf -ErrorAction SilentlyContinue)) {
|
|
return $null
|
|
}
|
|
try { return [System.Diagnostics.FileVersionInfo]::GetVersionInfo($Path) }
|
|
catch { return $null }
|
|
}
|
|
|
|
function Test-IsUserWritablePath {
|
|
param([AllowNull()] [string]$Path)
|
|
if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
|
|
$p = $Path.ToLowerInvariant()
|
|
foreach ($fragment in @('\temp\', '\tmp\', '\downloads\', '\desktop\', 'c:\users\public\')) {
|
|
if ($p.Contains($fragment)) { return $true }
|
|
}
|
|
if ($p.Contains('\appdata\')) { return $true }
|
|
if ($p -match '^[a-z]:\\users\\[^\\]+\\') { return $true }
|
|
return $false
|
|
}
|
|
|
|
function Test-IsApprovedArtifact {
|
|
param(
|
|
[AllowNull()] [string]$Name,
|
|
[AllowNull()] [string]$Path,
|
|
[AllowNull()] [string]$Hostname,
|
|
[AllowNull()] [string]$Publisher,
|
|
[Parameter(Mandatory)] [hashtable]$Config
|
|
)
|
|
|
|
if ($Name -and (Test-MatchesAnyExact -Value $Name -Indicators $Config.ApprovedProcessNames)) { return $true }
|
|
if ($Path -and (Test-MatchesAnyPattern -Value $Path -Patterns $Config.ApprovedPathPatterns)) { return $true }
|
|
if ($Hostname -and (Test-DomainMatch -Hostname $Hostname -Domains $Config.ApprovedDomains)) { return $true }
|
|
|
|
if ($Publisher) {
|
|
# Extract CN field from Authenticode cert subject for a tighter comparison.
|
|
# Raw subject format: "CN=Splashtop Inc., O=Splashtop Inc., L=San Jose, S=California, C=US"
|
|
$publisherCN = $Publisher
|
|
$cnMatch = [regex]::Match($Publisher, 'CN=([^,]+)')
|
|
if ($cnMatch.Success) { $publisherCN = $cnMatch.Groups[1].Value.Trim() }
|
|
|
|
foreach ($allowedPublisher in $Config.ApprovedPublishers) {
|
|
if ([string]::IsNullOrWhiteSpace($allowedPublisher)) { continue }
|
|
if ($publisherCN -like "*$allowedPublisher*") { return $true }
|
|
}
|
|
}
|
|
|
|
return $false
|
|
}
|
|
|
|
# --- Detection functions -------------------------------------------------------
|
|
|
|
function Get-RunningProcessDetections {
|
|
param(
|
|
[Parameter(Mandatory)] [string[]]$ProcessIndicators,
|
|
[Parameter(Mandatory)] [string[]]$PathIndicators,
|
|
[Parameter(Mandatory)] [string[]]$PEIndicators,
|
|
[Parameter(Mandatory)] [hashtable]$ProcessNameMap,
|
|
[Parameter(Mandatory)] [hashtable]$PathMap,
|
|
[Parameter(Mandatory)] [hashtable]$PENameMap,
|
|
[Parameter(Mandatory)] [hashtable]$Config
|
|
)
|
|
|
|
$results = New-Object System.Collections.Generic.List[object]
|
|
|
|
foreach ($p in Get-CimInstance Win32_Process) {
|
|
$name = ConvertTo-NormalizedString $p.Name
|
|
$path = ConvertTo-NormalizedString $p.ExecutablePath
|
|
$nameNoExt = if ($name) { [System.IO.Path]::GetFileNameWithoutExtension($name) } else { $null }
|
|
if (-not $name) { continue }
|
|
if ($Config.ExcludedProcessNames -contains $name -or
|
|
$Config.ExcludedProcessNames -contains $nameNoExt) { continue }
|
|
|
|
# Fetch PE version info once per process (fast - reads resource section only)
|
|
$peInfo = $null
|
|
if ($Config.EnablePEVersionCheck -and $p.ExecutablePath) {
|
|
$peInfo = Get-PEVersionInfo -Path $p.ExecutablePath
|
|
}
|
|
|
|
# Primary match: exact process filename/stem, or known full installation path
|
|
$matched = $false
|
|
$matchedOn = $null
|
|
$matchedTools = New-Object System.Collections.Generic.HashSet[string]
|
|
if ((Test-MatchesAnyExact -Value $name -Indicators $ProcessIndicators) -or
|
|
(Test-MatchesAnyExact -Value $nameNoExt -Indicators $ProcessIndicators) -or
|
|
(Test-MatchesAnyFullPath -Value $path -Indicators $PathIndicators)) {
|
|
$matched = $true
|
|
$matchedOn = 'ProcessNameOrPath'
|
|
foreach ($tool in (Get-MappedToolsExact -Value $name -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsExact -Value $nameNoExt -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsForPath -Value $path -Map $PathMap)) { [void]$matchedTools.Add($tool) }
|
|
}
|
|
|
|
# Secondary match: exact PE product/description metadata (catches renamed binaries
|
|
# without broad substring matching against generic words like "service" or "server").
|
|
if (-not $matched -and $peInfo) {
|
|
$peMatched =
|
|
(Test-MatchesAnyExact -Value (ConvertTo-NormalizedString $peInfo.ProductName) -Indicators $PEIndicators) -or
|
|
(Test-MatchesAnyExact -Value (ConvertTo-NormalizedString $peInfo.FileDescription) -Indicators $PEIndicators)
|
|
if ($peMatched) {
|
|
$matched = $true
|
|
$matchedOn = 'PEVersionInfo'
|
|
foreach ($tool in (Get-MappedToolsExact -Value (ConvertTo-NormalizedString $peInfo.ProductName) -Map $PENameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsExact -Value (ConvertTo-NormalizedString $peInfo.FileDescription) -Map $PENameMap)) { [void]$matchedTools.Add($tool) }
|
|
}
|
|
}
|
|
|
|
if (-not $matched) { continue }
|
|
if ((Get-SafeCount $matchedTools) -eq 0) { continue }
|
|
|
|
$publisher = Get-ProcessPublisher -Path $p.ExecutablePath
|
|
$userWritable = Test-IsUserWritablePath -Path $p.ExecutablePath
|
|
if (Test-IsApprovedArtifact -Name $name -Path $path -Hostname $null -Publisher $publisher -Config $Config) { continue }
|
|
|
|
$results.Add([PSCustomObject]@{
|
|
Layer = 'Process'
|
|
ToolHint = (($matchedTools | Sort-Object) -join ', ')
|
|
MatchedTools = [string[]]($matchedTools | Sort-Object)
|
|
Name = $p.Name
|
|
Path = $p.ExecutablePath
|
|
PID = $p.ProcessId
|
|
CommandLine = $p.CommandLine
|
|
Publisher = $publisher
|
|
ProductName = if ($peInfo) { $peInfo.ProductName } else { $null }
|
|
CompanyName = if ($peInfo) { $peInfo.CompanyName } else { $null }
|
|
UserWritable = $userWritable
|
|
MatchedOn = $matchedOn
|
|
})
|
|
}
|
|
|
|
return $results
|
|
}
|
|
|
|
function Get-ServiceDetections {
|
|
param(
|
|
[Parameter(Mandatory)] [string[]]$ProcessIndicators,
|
|
[Parameter(Mandatory)] [string[]]$PathIndicators,
|
|
[Parameter(Mandatory)] [hashtable]$ProcessNameMap,
|
|
[Parameter(Mandatory)] [hashtable]$PathMap,
|
|
[Parameter(Mandatory)] [hashtable]$Config
|
|
)
|
|
|
|
$results = New-Object System.Collections.Generic.List[object]
|
|
|
|
foreach ($svc in Get-CimInstance Win32_Service) {
|
|
$name = ConvertTo-NormalizedString $svc.Name
|
|
$displayName = ConvertTo-NormalizedString $svc.DisplayName
|
|
$pathName = ConvertTo-NormalizedString $svc.PathName
|
|
|
|
$svcNameNoExt = if ($name) { [System.IO.Path]::GetFileNameWithoutExtension($name) } else { $null }
|
|
$matchedTools = New-Object System.Collections.Generic.HashSet[string]
|
|
$matched = (Test-MatchesAnyExact -Value $name -Indicators $ProcessIndicators) -or
|
|
(Test-MatchesAnyExact -Value $svcNameNoExt -Indicators $ProcessIndicators) -or
|
|
(Test-MatchesAnyExact -Value $displayName -Indicators $ProcessIndicators) -or
|
|
(Test-MatchesAnyFullPath -Value $pathName -Indicators $PathIndicators)
|
|
if (-not $matched) { continue }
|
|
foreach ($tool in (Get-MappedToolsExact -Value $name -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsExact -Value $svcNameNoExt -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsExact -Value $displayName -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsForPath -Value $pathName -Map $PathMap)) { [void]$matchedTools.Add($tool) }
|
|
if ((Get-SafeCount $matchedTools) -eq 0) { continue }
|
|
|
|
$binaryPath = $svc.PathName
|
|
if ($binaryPath -match '^["'']?([^"'']+\.exe)') { $binaryPath = $matches[1] }
|
|
$publisher = Get-ProcessPublisher -Path $binaryPath
|
|
|
|
if (Test-IsApprovedArtifact -Name $svc.Name -Path $svc.PathName -Hostname $null -Publisher $publisher -Config $Config) { continue }
|
|
|
|
$results.Add([PSCustomObject]@{
|
|
Layer = 'Service'
|
|
ToolHint = (($matchedTools | Sort-Object) -join ', ')
|
|
MatchedTools = [string[]]($matchedTools | Sort-Object)
|
|
Name = $svc.Name
|
|
DisplayName = $svc.DisplayName
|
|
State = $svc.State
|
|
StartMode = $svc.StartMode
|
|
Path = $svc.PathName
|
|
Publisher = $publisher
|
|
MatchedOn = 'ServiceNameOrPath'
|
|
})
|
|
}
|
|
|
|
return $results
|
|
}
|
|
|
|
function Get-NetworkDetections {
|
|
param(
|
|
[Parameter(Mandatory)] [string[]]$Domains,
|
|
[Parameter(Mandatory)] [string[]]$ProcessIndicators,
|
|
[Parameter(Mandatory)] [string[]]$PathIndicators,
|
|
[Parameter(Mandatory)] [hashtable]$DomainMap,
|
|
[Parameter(Mandatory)] [hashtable]$ProcessNameMap,
|
|
[Parameter(Mandatory)] [hashtable]$PathMap,
|
|
[Parameter(Mandatory)] [hashtable]$Config
|
|
)
|
|
|
|
$results = New-Object System.Collections.Generic.List[object]
|
|
|
|
$connections = Get-NetTCPConnection -State Established -ErrorAction SilentlyContinue |
|
|
Where-Object { $_.RemoteAddress -and $_.OwningProcess -and
|
|
$_.RemoteAddress -notmatch '^(0\.0\.0\.0|::|::1)$' } |
|
|
Select-Object -First $Config.MaxConnectionsToInspect
|
|
|
|
foreach ($conn in $connections) {
|
|
$proc = $null
|
|
try { $proc = Get-CimInstance Win32_Process -Filter "ProcessId = $($conn.OwningProcess)" -ErrorAction Stop }
|
|
catch { continue }
|
|
|
|
$procName = ConvertTo-NormalizedString (Get-PropSafe $proc 'Name')
|
|
$procPath = ConvertTo-NormalizedString (Get-PropSafe $proc 'ExecutablePath')
|
|
$publisher = Get-ProcessPublisher -Path (Get-PropSafe $proc 'ExecutablePath')
|
|
|
|
$hostname = $null
|
|
$canResolve = $Config.ResolveReverseDns
|
|
if (-not $Config.ResolveReverseDnsPrivateIPs -and (Test-IsPrivateIPv4 -IpAddress $conn.RemoteAddress)) {
|
|
$canResolve = $false
|
|
}
|
|
if ($canResolve) { $hostname = Resolve-Hostname -IpAddress $conn.RemoteAddress }
|
|
$normalizedHost = ConvertTo-NormalizedString $hostname
|
|
|
|
$matched = $false
|
|
$matchedOn = @()
|
|
$matchedTools = New-Object System.Collections.Generic.HashSet[string]
|
|
|
|
$procNameNoExt = if ($procName) { [System.IO.Path]::GetFileNameWithoutExtension($procName) } else { $null }
|
|
if ($normalizedHost -and (Test-DomainMatch -Hostname $normalizedHost -Domains $Domains)) { $matched = $true; $matchedOn += 'ReverseDNSDomain'; foreach ($tool in (Get-MappedToolsForDomain -Hostname $normalizedHost -Map $DomainMap)) { [void]$matchedTools.Add($tool) } }
|
|
if ($procName -and (Test-MatchesAnyExact -Value $procName -Indicators $ProcessIndicators)) { $matched = $true; $matchedOn += 'OwningProcessName'; foreach ($tool in (Get-MappedToolsExact -Value $procName -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) } }
|
|
if ($procNameNoExt -and (Test-MatchesAnyExact -Value $procNameNoExt -Indicators $ProcessIndicators)) { $matched = $true; $matchedOn += 'OwningProcessName'; foreach ($tool in (Get-MappedToolsExact -Value $procNameNoExt -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) } }
|
|
if ($procPath -and (Test-MatchesAnyFullPath -Value $procPath -Indicators $PathIndicators)) { $matched = $true; $matchedOn += 'OwningProcessPath'; foreach ($tool in (Get-MappedToolsForPath -Value $procPath -Map $PathMap)) { [void]$matchedTools.Add($tool) } }
|
|
if (-not $matched) { continue }
|
|
if ((Get-SafeCount $matchedTools) -eq 0) { continue }
|
|
|
|
if (Test-IsApprovedArtifact -Name $procName -Path $procPath -Hostname $normalizedHost -Publisher $publisher -Config $Config) { continue }
|
|
|
|
$results.Add([PSCustomObject]@{
|
|
Layer = 'Network'
|
|
ToolHint = (($matchedTools | Sort-Object) -join ', ')
|
|
MatchedTools = [string[]]($matchedTools | Sort-Object)
|
|
PID = $conn.OwningProcess
|
|
ProcessName = (Get-PropSafe $proc 'Name')
|
|
ProcessPath = (Get-PropSafe $proc 'ExecutablePath')
|
|
Publisher = $publisher
|
|
LocalAddress = $conn.LocalAddress
|
|
LocalPort = $conn.LocalPort
|
|
RemoteAddress = $conn.RemoteAddress
|
|
RemotePort = $conn.RemotePort
|
|
Hostname = $hostname
|
|
MatchedOn = ($matchedOn -join ',')
|
|
})
|
|
}
|
|
|
|
return $results
|
|
}
|
|
|
|
function Get-ScheduledTaskDetections {
|
|
param(
|
|
[Parameter(Mandatory)] [string[]]$ProcessIndicators,
|
|
[Parameter(Mandatory)] [string[]]$PathIndicators,
|
|
[Parameter(Mandatory)] [hashtable]$ProcessNameMap,
|
|
[Parameter(Mandatory)] [hashtable]$PathMap,
|
|
[Parameter(Mandatory)] [hashtable]$Config
|
|
)
|
|
|
|
$results = New-Object System.Collections.Generic.List[object]
|
|
$tasks = Get-ScheduledTask -ErrorAction SilentlyContinue
|
|
if (-not $tasks) { return $results }
|
|
|
|
foreach ($task in $tasks) {
|
|
# Skip Microsoft built-in task paths
|
|
$taskPath = [string]$task.TaskPath
|
|
$skip = $false
|
|
foreach ($excluded in $Config.ExcludedTaskPaths) {
|
|
if ($taskPath -like "$excluded*") { $skip = $true; break }
|
|
}
|
|
if ($skip) { continue }
|
|
|
|
$taskName = ConvertTo-NormalizedString $task.TaskName
|
|
|
|
foreach ($action in @($task.Actions)) {
|
|
# Only inspect executable actions; COM handler actions have no Execute property
|
|
$execVal = try { [string]$action.Execute } catch { $null }
|
|
if ([string]::IsNullOrWhiteSpace($execVal)) { continue }
|
|
|
|
$execPath = ConvertTo-NormalizedString $execVal
|
|
$argsVal = ConvertTo-NormalizedString ([string]$action.Arguments)
|
|
|
|
$execLeaf = if ($execPath) { try { Split-Path -Path $execPath -Leaf } catch { $null } } else { $null }
|
|
$execLeafNoExt = if ($execLeaf) { try { [System.IO.Path]::GetFileNameWithoutExtension($execLeaf) } catch { $null } } else { $null }
|
|
$matchedTools = New-Object System.Collections.Generic.HashSet[string]
|
|
$matched = (Test-MatchesAnyExact -Value $taskName -Indicators $ProcessIndicators) -or
|
|
(Test-MatchesAnyExact -Value $execLeaf -Indicators $ProcessIndicators) -or
|
|
(Test-MatchesAnyExact -Value $execLeafNoExt -Indicators $ProcessIndicators) -or
|
|
(Test-MatchesAnyFullPath -Value $execPath -Indicators $PathIndicators) -or
|
|
(Test-MatchesAnyFullPath -Value $argsVal -Indicators $PathIndicators)
|
|
if (-not $matched) { continue }
|
|
foreach ($tool in (Get-MappedToolsExact -Value $taskName -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsExact -Value $execLeaf -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsExact -Value $execLeafNoExt -Map $ProcessNameMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsForPath -Value $execPath -Map $PathMap)) { [void]$matchedTools.Add($tool) }
|
|
foreach ($tool in (Get-MappedToolsForPath -Value $argsVal -Map $PathMap)) { [void]$matchedTools.Add($tool) }
|
|
if ((Get-SafeCount $matchedTools) -eq 0) { continue }
|
|
|
|
$publisher = Get-ProcessPublisher -Path $execVal
|
|
if (Test-IsApprovedArtifact -Name $taskName -Path $execPath -Hostname $null -Publisher $publisher -Config $Config) { continue }
|
|
|
|
$results.Add([PSCustomObject]@{
|
|
Layer = 'ScheduledTask'
|
|
ToolHint = (($matchedTools | Sort-Object) -join ', ')
|
|
MatchedTools = [string[]]($matchedTools | Sort-Object)
|
|
TaskName = $task.TaskName
|
|
TaskPath = $task.TaskPath
|
|
Execute = $execVal
|
|
Arguments = $action.Arguments
|
|
State = [string]$task.State
|
|
Publisher = $publisher
|
|
MatchedOn = 'TaskNameOrExecutable'
|
|
})
|
|
break # One detection per task is sufficient
|
|
}
|
|
}
|
|
|
|
return $results
|
|
}
|
|
|
|
function Get-RegistryArtifactDetections {
|
|
# Targeted registry key existence checks derived from LOLRMM Artifacts.Registry data.
|
|
# This is lightweight: only keys explicitly listed in the catalog are checked.
|
|
# HKCU keys are skipped when running as SYSTEM (would check SYSTEM's own hive, not users').
|
|
param(
|
|
[Parameter(Mandatory)] [object[]]$RegistryPaths,
|
|
[Parameter(Mandatory)] [hashtable]$Config
|
|
)
|
|
|
|
$results = New-Object System.Collections.Generic.List[object]
|
|
$seen = @{} # One detection per tool name
|
|
|
|
foreach ($entry in $RegistryPaths) {
|
|
$toolName = $entry.ToolName
|
|
if ($seen.ContainsKey($toolName)) { continue }
|
|
|
|
# Normalize registry path to PowerShell PSDrive format
|
|
$psPath = $entry.RegPath
|
|
$psPath = $psPath -replace '^HKEY_LOCAL_MACHINE\\', 'HKLM:\'
|
|
$psPath = $psPath -replace '^HKLM\\', 'HKLM:\'
|
|
$psPath = $psPath -replace '^HKEY_CURRENT_USER\\', 'HKCU:\'
|
|
$psPath = $psPath -replace '^HKCU\\', 'HKCU:\'
|
|
|
|
# Only HKLM is reliable when running as SYSTEM
|
|
if (-not $psPath.StartsWith('HKLM:\')) { continue }
|
|
if (-not (Test-Path -LiteralPath $psPath -ErrorAction SilentlyContinue)) { continue }
|
|
|
|
# If the artifact specifies a value name, verify it exists under the key
|
|
if (-not [string]::IsNullOrWhiteSpace($entry.Value)) {
|
|
try {
|
|
$regItem = Get-ItemProperty -LiteralPath $psPath -Name $entry.Value -ErrorAction Stop
|
|
if (-not $regItem) { continue }
|
|
}
|
|
catch { continue }
|
|
}
|
|
|
|
$toolNameNorm = ConvertTo-NormalizedString $toolName
|
|
if (Test-IsApprovedArtifact -Name $toolNameNorm -Path $null -Hostname $null -Publisher $null -Config $Config) { continue }
|
|
|
|
$seen[$toolName] = $true
|
|
$results.Add([PSCustomObject]@{
|
|
Layer = 'RegistryArtifact'
|
|
ToolHint = $toolName
|
|
ToolName = $toolName
|
|
RegPath = $entry.RegPath
|
|
RegValue = $entry.Value
|
|
MatchedOn = 'RegistryArtifact'
|
|
})
|
|
}
|
|
|
|
return $results
|
|
}
|
|
|
|
# --- State tracking ------------------------------------------------------------
|
|
|
|
function Get-DetectionStateKey {
|
|
param([Parameter(Mandatory)] [object]$Detection)
|
|
switch ($Detection.Layer) {
|
|
'Process' { return "Process|$($Detection.Path)|$($Detection.Name)" }
|
|
'Service' { return "Service|$($Detection.Name)|$($Detection.Path)" }
|
|
'Network' { return "Network|$($Detection.ProcessPath)|$($Detection.RemoteAddress):$($Detection.RemotePort)" }
|
|
'ScheduledTask' { return "Task|$($Detection.TaskPath)$($Detection.TaskName)|$($Detection.Execute)" }
|
|
'RegistryArtifact' { return "Registry|$($Detection.ToolName)|$($Detection.RegPath)" }
|
|
default { return "$($Detection.Layer)|$($Detection.ToolHint)" }
|
|
}
|
|
}
|
|
|
|
function Load-DetectionState {
|
|
param([Parameter(Mandatory)] [string]$StatePath)
|
|
if (-not (Test-Path -LiteralPath $StatePath -ErrorAction SilentlyContinue)) { return @{} }
|
|
try {
|
|
$obj = (Get-Content -LiteralPath $StatePath -Raw -ErrorAction Stop) | ConvertFrom-Json
|
|
$ht = @{}
|
|
foreach ($prop in $obj.PSObject.Properties) { $ht[$prop.Name] = $true }
|
|
return $ht
|
|
}
|
|
catch {
|
|
Write-Warning "Could not load detection state: $($_.Exception.Message)"
|
|
return @{}
|
|
}
|
|
}
|
|
|
|
function Save-DetectionState {
|
|
param(
|
|
[Parameter(Mandatory)] [string]$StatePath,
|
|
[Parameter(Mandatory)] [string[]]$Keys
|
|
)
|
|
try {
|
|
Ensure-ParentDirectory -Path $StatePath
|
|
$timestamp = Get-Date -Format s
|
|
$ht = @{}
|
|
foreach ($key in $Keys) { $ht[$key] = $timestamp }
|
|
($ht | ConvertTo-Json -Depth 5) | Set-Content -LiteralPath $StatePath -Encoding UTF8 -Force
|
|
}
|
|
catch {
|
|
Write-Warning "Could not save detection state: $($_.Exception.Message)"
|
|
}
|
|
}
|
|
|
|
# --- Alert body builder --------------------------------------------------------
|
|
|
|
function Build-AlertBody {
|
|
param(
|
|
[Parameter(Mandatory)] [System.Collections.Generic.List[object]]$Detections,
|
|
[Parameter(Mandatory)] [string]$FeedSource,
|
|
[Parameter(Mandatory)] [hashtable]$Config,
|
|
[switch]$Truncate
|
|
)
|
|
|
|
$newCount = Get-SafeCount (@($Detections | Where-Object { $_.Status -eq 'NEW' }))
|
|
$recurringCount = Get-SafeCount (@($Detections | Where-Object { $_.Status -eq 'RECURRING' }))
|
|
$totalCount = Get-SafeCount $Detections
|
|
|
|
$lines = New-Object System.Collections.Generic.List[string]
|
|
$lines.Add("Unauthorized RMM indicators detected.")
|
|
$lines.Add("Feed source : $FeedSource")
|
|
$lines.Add("Host : $env:COMPUTERNAME")
|
|
$lines.Add("Timestamp : $(Get-Date -Format s)")
|
|
$lines.Add("Detections : $totalCount total | $newCount NEW | $recurringCount RECURRING")
|
|
$lines.Add("")
|
|
|
|
$grouped = $Detections | Group-Object Layer
|
|
foreach ($group in $grouped) {
|
|
$lines.Add("[$($group.Name)]")
|
|
$items = @($group.Group)
|
|
$limit = if ($group.Name -eq 'Network') { $Config.MaxNetworkMatchesInAlert } else { 25 }
|
|
|
|
foreach ($item in ($items | Select-Object -First $limit)) {
|
|
$tag = "[$($item.Status)] "
|
|
$riskFlag = if (Get-PropSafe $item 'UserWritable') { ' *** USER-WRITABLE PATH ***' } else { '' }
|
|
|
|
switch ($group.Name) {
|
|
'Process' {
|
|
$lines.Add("- ${tag}Tool=$($item.ToolHint); Name=$($item.Name); PID=$($item.PID); Path=$($item.Path)$riskFlag; Publisher=$($item.Publisher); ProductName=$($item.ProductName); Match=$($item.MatchedOn)")
|
|
}
|
|
'Service' {
|
|
$lines.Add("- ${tag}Tool=$($item.ToolHint); Name=$($item.Name); State=$($item.State); StartMode=$($item.StartMode); Path=$($item.Path); Publisher=$($item.Publisher)")
|
|
}
|
|
'Network' {
|
|
$lines.Add("- ${tag}Tool=$($item.ToolHint); Process=$($item.ProcessName); PID=$($item.PID); Remote=$($item.RemoteAddress):$($item.RemotePort); Hostname=$($item.Hostname); Path=$($item.ProcessPath); Match=$($item.MatchedOn)")
|
|
}
|
|
'ScheduledTask' {
|
|
$lines.Add("- ${tag}Tool=$($item.ToolHint); Task=$($item.TaskPath)$($item.TaskName); Execute=$($item.Execute); Args=$($item.Arguments); State=$($item.State); Publisher=$($item.Publisher)")
|
|
}
|
|
'RegistryArtifact' {
|
|
$lines.Add("- ${tag}Tool=$($item.ToolName); RegPath=$($item.RegPath); Value=$($item.RegValue)")
|
|
}
|
|
}
|
|
}
|
|
|
|
if ((Get-SafeCount $items) -gt $limit) {
|
|
$lines.Add(" ... $((Get-SafeCount $items) - $limit) additional entries truncated")
|
|
}
|
|
$lines.Add("")
|
|
}
|
|
|
|
$body = ($lines -join [Environment]::NewLine)
|
|
if ($Truncate -and $body.Length -gt $Config.MaxAlertBodyLength) {
|
|
return $body.Substring(0, $Config.MaxAlertBodyLength) + "`r`n... truncated. Full report: $($Config.ReportPath)"
|
|
}
|
|
return $body
|
|
}
|
|
|
|
# --- Main ----------------------------------------------------------------------
|
|
|
|
$exitCode = 0
|
|
try {
|
|
# Harden the cache directory ACL on first use (best-effort, runs as SYSTEM)
|
|
if ($Config.HardenCacheDirectoryAcl) {
|
|
$cacheDir = Split-Path -Path $Config.CachePath -Parent
|
|
if (-not (Test-Path -LiteralPath $cacheDir)) {
|
|
New-Item -Path $cacheDir -ItemType Directory -Force | Out-Null
|
|
}
|
|
Set-CacheDirectoryAcl -DirectoryPath $cacheDir
|
|
}
|
|
|
|
# Fetch LOLRMM feed (live or verified cache)
|
|
$feed = Get-JsonFromUrlOrCache `
|
|
-Url $Config.LolrmmApiUrl `
|
|
-CachePath $Config.CachePath `
|
|
-CacheHashPath $Config.CacheHashPath `
|
|
-CacheMaxAgeHours $Config.CacheMaxAgeHours `
|
|
-MinFeedEntryCount $Config.MinFeedEntryCount
|
|
|
|
$catalog = @($feed.Data)
|
|
Write-Log "Loaded $(Get-SafeCount $catalog) LOLRMM catalog entries from $($feed.Source) source."
|
|
|
|
$indicators = Get-LolrmmIndicators -Catalog $catalog -Config $Config
|
|
Write-Log "Indicators: $(Get-SafeCount $indicators.ProcessNames) process names, $(Get-SafeCount $indicators.PENames) PE names, $(Get-SafeCount $indicators.PathPatterns) path patterns, $(Get-SafeCount $indicators.Domains) domains, $(Get-SafeCount $indicators.RegistryPaths) registry artifact paths."
|
|
|
|
# Load prior state for NEW vs RECURRING classification
|
|
$priorState = Load-DetectionState -StatePath $Config.StatePath
|
|
|
|
# Run all detection layers
|
|
$allDetections = New-Object System.Collections.Generic.List[object]
|
|
|
|
Write-Log "Running process detection..."
|
|
foreach ($d in (Get-RunningProcessDetections -ProcessIndicators $indicators.ProcessNames -PathIndicators $indicators.PathPatterns -PEIndicators $indicators.PENames -ProcessNameMap $indicators.ProcessNameMap -PathMap $indicators.PathMap -PENameMap $indicators.PENameMap -Config $Config)) { $allDetections.Add($d) }
|
|
|
|
Write-Log "Running service detection..."
|
|
foreach ($d in (Get-ServiceDetections -ProcessIndicators $indicators.ProcessNames -PathIndicators $indicators.PathPatterns -ProcessNameMap $indicators.ProcessNameMap -PathMap $indicators.PathMap -Config $Config)) { $allDetections.Add($d) }
|
|
|
|
Write-Log "Running network detection..."
|
|
foreach ($d in (Get-NetworkDetections -Domains $indicators.Domains -ProcessIndicators $indicators.ProcessNames -PathIndicators $indicators.PathPatterns -DomainMap $indicators.DomainMap -ProcessNameMap $indicators.ProcessNameMap -PathMap $indicators.PathMap -Config $Config)) { $allDetections.Add($d) }
|
|
|
|
if ($Config.EnableScheduledTaskDetection) {
|
|
Write-Log "Running scheduled task detection..."
|
|
foreach ($d in (Get-ScheduledTaskDetections -ProcessIndicators $indicators.ProcessNames -PathIndicators $indicators.PathPatterns -ProcessNameMap $indicators.ProcessNameMap -PathMap $indicators.PathMap -Config $Config)) { $allDetections.Add($d) }
|
|
}
|
|
|
|
if ($Config.EnableRegistryArtifacts -and (Get-SafeCount $indicators.RegistryPaths) -gt 0) {
|
|
Write-Log "Running registry artifact detection ($(Get-SafeCount $indicators.RegistryPaths) artifact paths)..."
|
|
foreach ($d in (Get-RegistryArtifactDetections -RegistryPaths $indicators.RegistryPaths -Config $Config)) { $allDetections.Add($d) }
|
|
}
|
|
|
|
# Deduplicate
|
|
$uniqueDetections = New-Object System.Collections.Generic.List[object]
|
|
$seen = @{}
|
|
foreach ($d in $allDetections) {
|
|
$key = ($d.PSObject.Properties | ForEach-Object { "{0}={1}" -f $_.Name, $_.Value }) -join '|'
|
|
if (-not $seen.ContainsKey($key)) {
|
|
$seen[$key] = $true
|
|
$uniqueDetections.Add($d)
|
|
}
|
|
}
|
|
|
|
# Annotate NEW vs RECURRING and attach state key
|
|
foreach ($d in $uniqueDetections) {
|
|
$stateKey = Get-DetectionStateKey -Detection $d
|
|
$statusVal = if ($priorState.ContainsKey($stateKey)) { 'RECURRING' } else { 'NEW' }
|
|
$d | Add-Member -NotePropertyName Status -NotePropertyValue $statusVal -Force
|
|
$d | Add-Member -NotePropertyName StateKey -NotePropertyValue $stateKey -Force
|
|
}
|
|
|
|
# Optionally suppress recurring detections to reduce alert noise
|
|
$alertableDetections = if ($Config.SuppressRecurringDetections) {
|
|
[System.Collections.Generic.List[object]]($uniqueDetections | Where-Object { $_.Status -eq 'NEW' })
|
|
}
|
|
else {
|
|
$uniqueDetections
|
|
}
|
|
|
|
# Persist state (all detections, new + recurring)
|
|
if ((Get-SafeCount $uniqueDetections) -gt 0) {
|
|
Save-DetectionState -StatePath $Config.StatePath -Keys @($uniqueDetections | ForEach-Object { $_.StateKey })
|
|
}
|
|
elseif (Test-Path -LiteralPath $Config.StatePath -ErrorAction SilentlyContinue) {
|
|
Remove-Item -LiteralPath $Config.StatePath -Force -ErrorAction SilentlyContinue
|
|
}
|
|
|
|
$newCount = Get-SafeCount (@($uniqueDetections | Where-Object { $_.Status -eq 'NEW' }))
|
|
$resultSummary = if ((Get-SafeCount $alertableDetections) -gt 0) {
|
|
"Detected $(Get-SafeCount $alertableDetections) unauthorized RMM indicator(s) ($newCount new, $((Get-SafeCount $uniqueDetections) - $newCount) recurring)."
|
|
}
|
|
else {
|
|
'No unauthorized RMM indicators detected.'
|
|
}
|
|
|
|
Write-Log $resultSummary
|
|
|
|
if ($Config.SetAssetFields -and (Get-Command Set-Asset-Field -ErrorAction SilentlyContinue)) {
|
|
Set-Asset-Field -Name $Config.AssetFieldLastScan -Value (Get-Date -Format s)
|
|
Set-Asset-Field -Name $Config.AssetFieldLastResult -Value $resultSummary
|
|
$top = ($uniqueDetections | Select-Object -First 20 | ForEach-Object { "$($_.Layer):$($_.ToolHint)" }) -join '; '
|
|
Set-Asset-Field -Name $Config.AssetFieldLastDetections -Value $top
|
|
}
|
|
|
|
if ((Get-SafeCount $alertableDetections) -gt 0) {
|
|
$fullReport = Build-AlertBody -Detections $alertableDetections -FeedSource $feed.Source -Config $Config
|
|
$alertBody = Build-AlertBody -Detections $alertableDetections -FeedSource $feed.Source -Config $Config -Truncate
|
|
|
|
# Write the full untruncated report to disk for technician review
|
|
try {
|
|
Ensure-ParentDirectory -Path $Config.ReportPath
|
|
Set-Content -LiteralPath $Config.ReportPath -Value $fullReport -Encoding UTF8 -Force
|
|
Write-Log "Full report written to $($Config.ReportPath)"
|
|
}
|
|
catch {
|
|
Write-Warning "Could not write report file: $($_.Exception.Message)"
|
|
}
|
|
|
|
if (Get-Command Rmm-Alert -ErrorAction SilentlyContinue) {
|
|
Rmm-Alert -Category $Config.AlertCategory -Body $alertBody
|
|
}
|
|
else {
|
|
Write-Warning 'Syncro Rmm-Alert not available in this execution context.'
|
|
Write-Output $alertBody
|
|
}
|
|
|
|
if (Get-Command Log-Activity -ErrorAction SilentlyContinue) {
|
|
Log-Activity -Message $resultSummary -EventName $Config.LogEventName
|
|
}
|
|
|
|
}
|
|
elseif ($Config.CloseAlertWhenClean -and (Get-Command Close-Rmm-Alert -ErrorAction SilentlyContinue)) {
|
|
Close-Rmm-Alert -Category $Config.AlertCategory -CloseAlertTicket $false
|
|
if (Get-Command Log-Activity -ErrorAction SilentlyContinue) {
|
|
Log-Activity -Message 'LOLRMM scan clean; prior alert category closed.' -EventName $Config.LogEventName
|
|
}
|
|
}
|
|
}
|
|
catch {
|
|
$message = "LOLRMM detector failed: $($_.Exception.Message)"
|
|
[Console]::Error.WriteLine($message)
|
|
|
|
if (Get-Command Rmm-Alert -ErrorAction SilentlyContinue) {
|
|
try { Rmm-Alert -Category 'unauthorized_rmm_detector_error' -Body $message } catch {}
|
|
}
|
|
|
|
$exitCode = 2
|
|
}
|
|
|
|
exit $exitCode
|