Cybertek-Detection-Scripts/lolrmm/lolrmm_syncro_detector.ps1

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