commit f83163f0c18c2be04afb2272bed8524c5af68108 Author: cole@cybertek.systems Date: Mon Mar 16 09:14:51 2026 -0500 Initial commit: network traffic monitor, lolrmm detector, event log monitor diff --git a/README.md b/README.md new file mode 100644 index 0000000..094ba42 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Cybertek Detection Scripts + +Syncro RMM scripts used by Cybertek Systems for endpoint security monitoring. Each script runs on a schedule via Syncro and raises alerts when suspicious activity is detected. + +--- + +## Scripts + +### Network Traffic Monitor +**`network_traffic_monitor/network_traffic_monitor.ps1`** + +Monitors active network connections for malicious IPs using live threat intelligence feeds (aggregated hourly from abuse.ch, Emerging Threats, and others). Raises a Syncro alert categorized as `network_traffic_critical` or `network_traffic_warning` depending on threat severity. + +--- + +### LoLRMM Detector +**`lolrmm/lolrmm_syncro_detector.ps1`** + +Detects unauthorized Remote Monitoring and Management (RMM) tools running on endpoints. Checks running processes and installed software against the [lolrmm.io](https://lolrmm.io) database of known RMM tools. Raises a Syncro alert if any unapproved RMM tool is found. + +--- + +### Event Log Monitor +**`event_log_monitoring/event_log_monitor.ps1`** + +Monitors the Windows Security Event Log for high-value security events — including failed logon attempts, privilege escalation, account lockouts, and suspicious process activity. Raises Syncro alerts categorized by severity. + +--- + +## Deployment + +All scripts are deployed and scheduled via Syncro RMM. Each script is self-contained and requires only a Syncro API key configured as a Script Variable within the Syncro platform. diff --git a/event_log_monitoring/event_log_monitor.ps1 b/event_log_monitoring/event_log_monitor.ps1 new file mode 100644 index 0000000..ff32019 --- /dev/null +++ b/event_log_monitoring/event_log_monitor.ps1 @@ -0,0 +1,597 @@ +#requires -Version 5.1 +<# + ______ __ __ __ + / ____/_ __/ /_ ___ _____/ /____ / /__ + / / / / / / __ \/ _ \/ ___/ __/ _ \/ //_/ + / /___/ /_/ / /_/ / __/ / / /_/ __/ ,< + \____/\__, /_.___/\___/_/ \__/\___/_/|_| + /____/ + + .SYNOPSIS + CYBERTEK Syncro monitor for Windows Security event log auditing. + + .DESCRIPTION + Monitors high-value Windows Security audit events from + Microsoft-Windows-Security-Auditing and creates Syncro alerts when new + matching events are found. The script stores the last processed RecordId + locally so recurring scheduled runs do not keep alerting on the same event. + + .NOTES + Script Name : event_log_monitor.ps1 + Author : Cybertek + Maintainer : Cybertek / Codex-assisted implementation + Version : 2026-03-14 + Run Context : SYSTEM via Syncro RMM + Purpose : Alert on new high-value Windows Security events with low noise. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module $env:SyncroModule + +$Config = [ordered]@{ + LogName = 'Security' + ProviderName = 'Microsoft-Windows-Security-Auditing' + AlertCategory = 'security_event_monitor' + AuditPolicyAlertCategory = 'security_event_monitor_audit_policy' + ErrorAlertCategory = 'security_event_monitor_error' + LogEventName = 'Security Event Monitoring' + StatePath = 'C:\ProgramData\Cybertek\SecurityEventMonitor\state.json' + ReportPath = 'C:\ProgramData\Cybertek\SecurityEventMonitor\last_report.txt' + InitialLookbackMins = 90 + MaxEventsPerAlert = 25 + MaxAlertBodyLength = 7000 + EnableAuditPolicyPreflight = $true + AutoEnableMissingAuditPolicy = $true + CloseAlertWhenClean = $false + CloseAuditPolicyAlertWhenClean = $true +} + +$MonitoredEvents = @( + [PSCustomObject]@{ Key='interactive_logon'; EventId=4624; Title='Interactive Logon'; MatchField='LogonType'; MatchValue='2' } + [PSCustomObject]@{ Key='rdp_logon'; EventId=4624; Title='RDP Logon'; MatchField='LogonType'; MatchValue='10' } + [PSCustomObject]@{ Key='failed_logon'; EventId=4625; Title='Failed Logon'; MatchField=$null; MatchValue=$null } + [PSCustomObject]@{ Key='explicit_creds'; EventId=4648; Title='Explicit Credentials Used'; MatchField=$null; MatchValue=$null } + [PSCustomObject]@{ Key='user_created'; EventId=4720; Title='Local User Created'; MatchField=$null; MatchValue=$null } + [PSCustomObject]@{ Key='user_removed_grp'; EventId=4729; Title='User Removed From Group'; MatchField=$null; MatchValue=$null } + [PSCustomObject]@{ Key='user_added_grp'; EventId=4732; Title='User Added To Group'; MatchField=$null; MatchValue=$null } + [PSCustomObject]@{ Key='acct_lockout'; EventId=4740; Title='Account Locked Out'; MatchField=$null; MatchValue=$null } + [PSCustomObject]@{ Key='user_logoff'; EventId=4634; Title='User Logoff'; MatchField=$null; MatchValue=$null } + [PSCustomObject]@{ Key='system_logoff'; EventId=4647; Title='Logoff Initiated By User'; MatchField=$null; MatchValue=$null } +) + +# Audit policy prerequisites are intentionally mapped at the event-family level. +# This keeps remediation understandable and avoids overfitting to individual IDs. +$RequiredAuditPolicies = @( + [PSCustomObject]@{ Subcategory='Logon'; RequireSuccess=$true; RequireFailure=$true; Purpose='4624 / 4625 / 4648 logon activity' } + [PSCustomObject]@{ Subcategory='Logoff'; RequireSuccess=$true; RequireFailure=$false; Purpose='4634 logoff activity' } + [PSCustomObject]@{ Subcategory='Other Logon/Logoff Events'; RequireSuccess=$true; RequireFailure=$false; Purpose='4647 user-initiated logoff activity' } + [PSCustomObject]@{ Subcategory='User Account Management'; RequireSuccess=$true; RequireFailure=$false; Purpose='4720 local user creation activity' } + [PSCustomObject]@{ Subcategory='Security Group Management'; RequireSuccess=$true; RequireFailure=$false; Purpose='4729 / 4732 group membership changes' } + [PSCustomObject]@{ Subcategory='Account Lockout'; RequireSuccess=$true; RequireFailure=$false; Purpose='4740 account lockout activity' } +) + +function Write-Log { + param([string]$Message) + [Console]::WriteLine("[SECMON] $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 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 ConvertTo-NormalizedString { + param([AllowNull()] [object]$Value) + if ($null -eq $Value) { return $null } + $s = [string]$Value + if ([string]::IsNullOrWhiteSpace($s)) { return $null } + return $s.Trim() +} + +function Load-State { + param([Parameter(Mandatory)] [string]$Path) + if (-not (Test-Path -LiteralPath $Path -ErrorAction SilentlyContinue)) { + return [PSCustomObject]@{ LastRecordId = 0 } + } + + try { + return (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json) + } + catch { + Write-Warning "Could not load state file: $($_.Exception.Message)" + return [PSCustomObject]@{ LastRecordId = 0 } + } +} + +function Save-State { + param( + [Parameter(Mandatory)] [string]$Path, + [Parameter(Mandatory)] [long]$LastRecordId + ) + + try { + Ensure-ParentDirectory -Path $Path + ([PSCustomObject]@{ + LastRecordId = $LastRecordId + UpdatedAt = (Get-Date -Format s) + } | ConvertTo-Json -Depth 4) | Set-Content -LiteralPath $Path -Encoding UTF8 -Force + } + catch { + Write-Warning "Could not save state file: $($_.Exception.Message)" + } +} + +function Close-AlertIfEnabled { + param( + [Parameter(Mandatory)] [string]$Category, + [Parameter(Mandatory)] [bool]$Enabled + ) + + if ($Enabled -and (Get-Command Close-Rmm-Alert -ErrorAction SilentlyContinue)) { + Close-Rmm-Alert -Category $Category -CloseAlertTicket $false + } +} + +function Get-AuditPolicyMap { + $map = @{} + $lines = @() + + try { + $lines = @(auditpol /get /category:* 2>&1) + } + catch { + throw "Could not run auditpol: $($_.Exception.Message)" + } + + foreach ($line in $lines) { + $text = [string]$line + if ([string]::IsNullOrWhiteSpace($text)) { continue } + if ($text -match '^\s{2,}(.+?)\s{2,}(.+)$') { + $subcategory = $matches[1].Trim() + $setting = $matches[2].Trim() + if ($subcategory -and $setting) { + $map[$subcategory] = $setting + } + } + } + + if ($map.Count -eq 0) { + throw 'auditpol returned no parseable audit policy data.' + } + + return $map +} + +function Test-AuditPolicyRequirement { + param( + [Parameter(Mandatory)] [string]$Setting, + [Parameter(Mandatory)] [bool]$RequireSuccess, + [Parameter(Mandatory)] [bool]$RequireFailure + ) + + $normalized = $Setting.ToLowerInvariant() + $hasSuccess = $normalized.Contains('success') + $hasFailure = $normalized.Contains('failure') + + if ($RequireSuccess -and -not $hasSuccess) { return $false } + if ($RequireFailure -and -not $hasFailure) { return $false } + return $true +} + +function Get-AuditPolicyFindings { + param( + [Parameter(Mandatory)] [hashtable]$AuditPolicyMap, + [Parameter(Mandatory)] [object[]]$Requirements + ) + + $findings = New-Object System.Collections.Generic.List[object] + + foreach ($requirement in $Requirements) { + $setting = $null + if ($AuditPolicyMap.ContainsKey($requirement.Subcategory)) { + $setting = [string]$AuditPolicyMap[$requirement.Subcategory] + } + + if ([string]::IsNullOrWhiteSpace($setting)) { + $findings.Add([PSCustomObject]@{ + Subcategory = $requirement.Subcategory + Current = 'Missing' + Expected = if ($requirement.RequireFailure) { 'Success and Failure' } elseif ($requirement.RequireSuccess) { 'Success' } else { 'Configured' } + Purpose = $requirement.Purpose + }) + continue + } + + if (-not (Test-AuditPolicyRequirement -Setting $setting -RequireSuccess $requirement.RequireSuccess -RequireFailure $requirement.RequireFailure)) { + $findings.Add([PSCustomObject]@{ + Subcategory = $requirement.Subcategory + Current = $setting + Expected = if ($requirement.RequireFailure) { 'Success and Failure' } elseif ($requirement.RequireSuccess) { 'Success' } else { 'Configured' } + Purpose = $requirement.Purpose + }) + } + } + + return $findings +} + +function Set-AuditPolicyRequirement { + param([Parameter(Mandatory)] [pscustomobject]$Requirement) + + $successArg = if ($Requirement.RequireSuccess) { 'enable' } else { 'disable' } + $failureArg = if ($Requirement.RequireFailure) { 'enable' } else { 'disable' } + + $output = @(auditpol /set /subcategory:"$($Requirement.Subcategory)" /success:$successArg /failure:$failureArg 2>&1) + $exitCode = $LASTEXITCODE + + return [PSCustomObject]@{ + Subcategory = $Requirement.Subcategory + Success = ($exitCode -eq 0) + ExitCode = $exitCode + Output = (($output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine) + } +} + +function Build-AuditPolicyRemediationBody { + param( + [Parameter(Mandatory)] [object[]]$Findings, + [Parameter(Mandatory)] [object[]]$RemediationResults + ) + + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('Windows Security auditing prerequisites were missing and remediation was attempted.') + $lines.Add("Host : $env:COMPUTERNAME") + $lines.Add("Timestamp : $(Get-Date -Format s)") + $lines.Add('') + + foreach ($item in $Findings) { + $result = @($RemediationResults | Where-Object { $_.Subcategory -eq $item.Subcategory } | Select-Object -First 1) + $status = if ((Get-SafeCount $result) -gt 0 -and $result[0].Success) { 'Remediated' } else { 'Failed' } + $lines.Add("- Subcategory=$($item.Subcategory); Current=$($item.Current); Expected=$($item.Expected); Result=$status; Purpose=$($item.Purpose)") + } + + return ($lines -join [Environment]::NewLine) +} + +function Build-AuditPolicyAlertBody { + param([Parameter(Mandatory)] [object[]]$Findings) + + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add('Windows Security auditing may be incomplete for this monitor.') + $lines.Add("Host : $env:COMPUTERNAME") + $lines.Add("Timestamp : $(Get-Date -Format s)") + $lines.Add('') + + foreach ($item in $Findings) { + $lines.Add("- Subcategory=$($item.Subcategory); Current=$($item.Current); Expected=$($item.Expected); Purpose=$($item.Purpose)") + } + + return ($lines -join [Environment]::NewLine) +} + +function Get-EventDataMap { + param([Parameter(Mandatory)] [System.Diagnostics.Eventing.Reader.EventRecord]$Event) + + $map = @{} + try { + $xml = [xml]$Event.ToXml() + foreach ($node in @($xml.Event.EventData.Data)) { + $name = [string]$node.Name + if ([string]::IsNullOrWhiteSpace($name)) { continue } + $map[$name] = [string]$node.'#text' + } + } + catch {} + + return $map +} + +function Resolve-EventDefinition { + param( + [Parameter(Mandatory)] [System.Diagnostics.Eventing.Reader.EventRecord]$Event, + [Parameter(Mandatory)] [object[]]$Definitions + ) + + $eventData = Get-EventDataMap -Event $Event + + foreach ($definition in $Definitions) { + if ($Event.Id -ne $definition.EventId) { continue } + if (-not $definition.MatchField) { + return [PSCustomObject]@{ + Definition = $definition + EventData = $eventData + } + } + + $value = $null + if ($eventData.ContainsKey($definition.MatchField)) { + $value = ConvertTo-NormalizedString $eventData[$definition.MatchField] + } + + if ($value -eq $definition.MatchValue) { + return [PSCustomObject]@{ + Definition = $definition + EventData = $eventData + } + } + } + + return $null +} + +function Get-PrimaryUser { + param([hashtable]$EventData) + foreach ($field in @('TargetUserName', 'SubjectUserName', 'TargetUser', 'AccountName')) { + if ($EventData.ContainsKey($field)) { + $value = ConvertTo-NormalizedString $EventData[$field] + if ($value -and $value -notin @('-', 'SYSTEM', 'ANONYMOUS LOGON')) { + return $value + } + } + } + return $null +} + +function Get-RemoteSource { + param([hashtable]$EventData) + foreach ($field in @('IpAddress', 'WorkstationName', 'ProcessName', 'IpPort')) { + if ($EventData.ContainsKey($field)) { + $value = ConvertTo-NormalizedString $EventData[$field] + if ($value -and $value -ne '-') { return $value } + } + } + return $null +} + +function New-MonitoredEvent { + param( + [Parameter(Mandatory)] [System.Diagnostics.Eventing.Reader.EventRecord]$Event, + [Parameter(Mandatory)] [hashtable]$EventData, + [Parameter(Mandatory)] [pscustomobject]$Definition + ) + + [PSCustomObject]@{ + RecordId = [long]$Event.RecordId + EventId = [int]$Event.Id + TimeCreated = $Event.TimeCreated + Title = $Definition.Title + Key = $Definition.Key + User = Get-PrimaryUser -EventData $EventData + Source = Get-RemoteSource -EventData $EventData + LogonType = if ($EventData.ContainsKey('LogonType')) { $EventData['LogonType'] } else { $null } + } +} + +function Get-EventSummaryLine { + param([Parameter(Mandatory)] [object]$Event) + + switch ($Event.Key) { + 'interactive_logon' { return "User '$($Event.User)' signed in locally." } + 'rdp_logon' { return "User '$($Event.User)' signed in through Remote Desktop." } + 'failed_logon' { return "A sign-in attempt for '$($Event.User)' failed." } + 'explicit_creds' { return "Explicit credentials were used for '$($Event.User)'." } + 'user_created' { return "A local user account '$($Event.User)' was created." } + 'user_removed_grp' { return "A user was removed from group '$($Event.User)'." } + 'user_added_grp' { return "A user was added to group '$($Event.User)'." } + 'acct_lockout' { return "The account '$($Event.User)' was locked out." } + 'user_logoff' { return "User '$($Event.User)' signed out." } + 'system_logoff' { return "A user-initiated logoff was recorded for '$($Event.User)'." } + default { return "$($Event.Title) detected for '$($Event.User)'." } + } +} + +function Build-AlertBody { + param( + [Parameter(Mandatory)] [System.Collections.Generic.List[object]]$Events, + [Parameter(Mandatory)] [hashtable]$Config, + [switch]$Truncate + ) + + $lines = New-Object System.Collections.Generic.List[string] + $lines.Add("Windows Security audit events detected.") + $lines.Add("Host : $env:COMPUTERNAME") + $lines.Add("Timestamp : $(Get-Date -Format s)") + $lines.Add("Event Count : $(Get-SafeCount $Events)") + $lines.Add("") + + $lines.Add("Summary") + foreach ($group in ($Events | Group-Object Title | Sort-Object Name)) { + $lines.Add("- $($group.Name): $(@($group.Group).Count)") + } + $lines.Add("") + + $lines.Add("Details") + + foreach ($item in ($Events | Select-Object -First $Config.MaxEventsPerAlert)) { + $lines.Add("- $($item.Title)") + $lines.Add(" What happened : $(Get-EventSummaryLine -Event $item)") + $lines.Add(" Time : $($item.TimeCreated)") + $lines.Add(" User : $($item.User)") + if ($item.Source) { + $lines.Add(" Source : $($item.Source)") + } + if ($item.LogonType) { + $lines.Add(" Logon Type : $($item.LogonType)") + } + $lines.Add(" Event ID : $($item.EventId)") + $lines.Add(" Record ID : $($item.RecordId)") + $lines.Add("") + } + + if ((Get-SafeCount $Events) -gt $Config.MaxEventsPerAlert) { + $lines.Add("... $((Get-SafeCount $Events) - $Config.MaxEventsPerAlert) additional events truncated") + } + + $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 +} + +$exitCode = 0 +try { + if ($Config.EnableAuditPolicyPreflight) { + try { + Write-Log 'Checking local audit policy prerequisites...' + $auditPolicyMap = Get-AuditPolicyMap + $auditFindings = @(Get-AuditPolicyFindings -AuditPolicyMap $auditPolicyMap -Requirements $RequiredAuditPolicies) + + if ((Get-SafeCount $auditFindings) -gt 0) { + $auditMessage = "Audit policy preflight found $(Get-SafeCount $auditFindings) prerequisite issue(s)." + Write-Warning $auditMessage + + if ($Config.AutoEnableMissingAuditPolicy) { + Write-Log 'Attempting to enable missing audit policy prerequisites...' + $remediationResults = New-Object System.Collections.Generic.List[object] + + foreach ($finding in $auditFindings) { + $requirement = @($RequiredAuditPolicies | Where-Object { $_.Subcategory -eq $finding.Subcategory } | Select-Object -First 1) + if ((Get-SafeCount $requirement) -eq 0) { continue } + $remediationResults.Add((Set-AuditPolicyRequirement -Requirement $requirement[0])) + } + + $failedRemediations = @($remediationResults | Where-Object { -not $_.Success }) + + if ((Get-SafeCount $failedRemediations) -gt 0) { + $auditBody = Build-AuditPolicyRemediationBody -Findings $auditFindings -RemediationResults $remediationResults + if (Get-Command Rmm-Alert -ErrorAction SilentlyContinue) { + Rmm-Alert -Category $Config.AuditPolicyAlertCategory -Body $auditBody + } + Write-Warning "Audit policy remediation failed for $(Get-SafeCount $failedRemediations) subcategory(s)." + foreach ($item in $failedRemediations) { + Write-Warning "Audit policy remediation failure: Subcategory=$($item.Subcategory); ExitCode=$($item.ExitCode); Output=$($item.Output)" + } + } + else { + Write-Log "Audit policy remediation completed successfully for $(Get-SafeCount $remediationResults) subcategory(s)." + if (Get-Command Log-Activity -ErrorAction SilentlyContinue) { + Log-Activity -Message "Audit policy remediation completed successfully for $(Get-SafeCount $remediationResults) subcategory(s)." -EventName $Config.LogEventName + } + Close-AlertIfEnabled -Category $Config.AuditPolicyAlertCategory -Enabled $Config.CloseAuditPolicyAlertWhenClean + } + } + elseif (Get-Command Rmm-Alert -ErrorAction SilentlyContinue) { + Rmm-Alert -Category $Config.AuditPolicyAlertCategory -Body (Build-AuditPolicyAlertBody -Findings $auditFindings) + } + + if (Get-Command Log-Activity -ErrorAction SilentlyContinue) { + Log-Activity -Message $auditMessage -EventName $Config.LogEventName + } + } + else { + Write-Log 'Audit policy preflight passed; required subcategories are already enabled.' + if (Get-Command Log-Activity -ErrorAction SilentlyContinue) { + Log-Activity -Message 'Audit policy preflight passed; required subcategories are already enabled.' -EventName $Config.LogEventName + } + Close-AlertIfEnabled -Category $Config.AuditPolicyAlertCategory -Enabled $Config.CloseAuditPolicyAlertWhenClean + } + } + catch { + Write-Warning "Audit policy preflight could not be completed: $($_.Exception.Message)" + } + } + + $state = Load-State -Path $Config.StatePath + $lastRecordId = 0 + if ($state.PSObject.Properties['LastRecordId']) { + $lastRecordId = [long]$state.LastRecordId + } + + $startTime = (Get-Date).AddMinutes(-1 * $Config.InitialLookbackMins) + $eventIds = @($MonitoredEvents | Select-Object -ExpandProperty EventId -Unique) + + Write-Log "Querying Windows Security log..." + try { + $rawEvents = @(Get-WinEvent -FilterHashtable @{ + LogName = $Config.LogName + ProviderName = $Config.ProviderName + Id = $eventIds + StartTime = $startTime + } -ErrorAction Stop | Sort-Object RecordId) + } + catch { + if ($_.Exception.Message -like '*No events were found that match the specified selection criteria*') { + $rawEvents = @() + } + else { + throw + } + } + + $matchedEvents = New-Object System.Collections.Generic.List[object] + $maxRecordId = $lastRecordId + + foreach ($event in @($rawEvents)) { + if ([long]$event.RecordId -le $lastRecordId) { continue } + + $resolved = Resolve-EventDefinition -Event $event -Definitions $MonitoredEvents + if ($null -eq $resolved) { continue } + + $matchedEvents.Add((New-MonitoredEvent -Event $event -EventData $resolved.EventData -Definition $resolved.Definition)) + if ([long]$event.RecordId -gt $maxRecordId) { + $maxRecordId = [long]$event.RecordId + } + } + + if ((Get-SafeCount $matchedEvents) -gt 0) { + $fullReport = Build-AlertBody -Events $matchedEvents -Config $Config + $alertBody = Build-AlertBody -Events $matchedEvents -Config $Config -Truncate + + try { + Ensure-ParentDirectory -Path $Config.ReportPath + Set-Content -LiteralPath $Config.ReportPath -Value $fullReport -Encoding UTF8 -Force + } + 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-Output $alertBody + } + + $summary = "Detected $(Get-SafeCount $matchedEvents) new Windows Security event(s)." + Write-Log $summary + if (Get-Command Log-Activity -ErrorAction SilentlyContinue) { + Log-Activity -Message $summary -EventName $Config.LogEventName + } + } + else { + Write-Log 'No new monitored Windows Security events detected.' + Close-AlertIfEnabled -Category $Config.AlertCategory -Enabled $Config.CloseAlertWhenClean + } + + Save-State -Path $Config.StatePath -LastRecordId $maxRecordId +} +catch { + $message = "Security event monitor failed: $($_.Exception.Message)" + [Console]::Error.WriteLine($message) + if (Get-Command Rmm-Alert -ErrorAction SilentlyContinue) { + try { Rmm-Alert -Category $Config.ErrorAlertCategory -Body $message } catch {} + } + $exitCode = 2 +} + +exit $exitCode diff --git a/event_log_monitoring/project.md b/event_log_monitoring/project.md new file mode 100644 index 0000000..ae7853c --- /dev/null +++ b/event_log_monitoring/project.md @@ -0,0 +1,273 @@ +# Project: Syncro Security Event Monitor + +## Summary + +A single self-contained PowerShell script for Syncro RMM that monitors selected Windows Security audit events, verifies the required audit-policy prerequisites, optionally auto-remediates missing audit subcategories, and creates de-duplicated Syncro alerts only for newly observed matching events. + +The script is intentionally stateful: + +- it reads the Windows `Security` log +- filters for specific `Microsoft-Windows-Security-Auditing` events +- stores the last processed `RecordId` +- alerts only on newly observed matching events + +This gives Cybertek a lightweight way to surface meaningful local security activity without repeatedly alerting on the same event every time the script runs. + +--- + +## Current Scope + +The current script focuses on account activity, authentication activity, and local group membership changes. + +It currently monitors: + +- `4624` interactive logon (`LogonType = 2`) +- `4624` remote interactive / RDP logon (`LogonType = 10`) +- `4625` failed logon +- `4634` logoff +- `4647` logoff initiated by user +- `4648` logon attempted with explicit credentials +- `4720` local user created +- `4729` user removed from group +- `4732` user added to group +- `4740` account locked out + +The script parses structured event XML instead of relying on fragile message-text spacing in Syncro custom event query filters. + +--- + +## Why This Exists + +Syncro's built-in custom event log queries can work well, but they are sensitive to exact message formatting and spacing, especially for Security audit events like `4624` logons where the same event ID represents multiple different scenarios. + +This script moves that logic into PowerShell so we can: + +- inspect event fields directly +- distinguish `4624` interactive logons from other `4624` records +- reduce duplicate alerting across scheduled runs +- expand logic over time without rebuilding Syncro query filters manually + +--- + +## Goals + +### Primary Goals + +1. Detect high-value local Windows Security audit events through a recurring Syncro script. +2. Alert only on newly observed matching events. +3. Make `4624` logon monitoring reliable by filtering on structured `LogonType`. +4. Keep the script lightweight enough for recurring execution. +5. Provide a clean foundation for future Cybertek security monitoring scripts. + +### Secondary Goals + +1. Write a local report file for technician review. +2. Keep alert content short but actionable. +3. Allow easy expansion to more Security event IDs later. + +### Non-Goals + +1. Full SIEM-style retention or analytics. +2. Historical correlation across all Windows logs. +3. Replacing proper advanced auditing / centralized logging platforms. +4. Full enterprise audit-policy management beyond the required subcategories for this script. + +--- + +## Architecture + +### Execution Model + +- **Language:** PowerShell 5.1+ +- **Runtime:** Syncro Windows script runner +- **Run Context:** SYSTEM +- **Alert Method:** `Rmm-Alert` + +### Data Source + +- Windows Event Log: `Security` +- Provider: `Microsoft-Windows-Security-Auditing` + +### Local Storage + +| Path | Purpose | +|------|---------| +| `C:\ProgramData\Cybertek\SecurityEventMonitor\state.json` | Stores the last processed `RecordId` | +| `C:\ProgramData\Cybertek\SecurityEventMonitor\last_report.txt` | Full alert report from the most recent matching run | + +--- + +## How Detection Works + +On each run, the script: + +1. Loads the last processed `RecordId` from local state. +2. Queries recent Security log events within the configured lookback window. +3. Filters down to the configured event IDs. +4. Parses event XML and checks structured fields such as `LogonType`. +5. Keeps only events newer than the last processed `RecordId`. +6. Sends one aggregated Syncro alert if new matching events are found. +7. Saves the newest seen `RecordId` so the next run does not re-alert on the same records. + +This is why the script behaves more like a small event-monitoring worker than a simple one-shot event search. + +--- + +## Alerting Strategy + +### Alert Category + +- `security_event_monitor` + +### Error Category + +- `security_event_monitor_error` + +### Audit Policy Category + +- `security_event_monitor_audit_policy` + +### Alert Content + +Each alert includes: + +- host name +- scan timestamp +- number of matching events +- summary counts grouped by event type +- per-event summary including: + - title + - plain-language description of what happened + - event ID + - record ID + - event time + - user + - source + - logon type where applicable + +The full alert report is also written locally to: + +- `C:\ProgramData\Cybertek\SecurityEventMonitor\last_report.txt` + +--- + +## Auditing Requirements + +This script depends on Windows Security auditing being enabled for the event categories it monitors, and it now attempts to correct missing required subcategories by default. + +Important: + +- the script attempts to enable missing audit-policy prerequisites by default with `AutoEnableMissingAuditPolicy = $true` +- the script **does** perform a preflight audit-policy check before querying events +- if required auditing is missing, the script raises a separate audit-policy alert category and continues with event monitoring +- if remediation succeeds, the audit-policy alert category is closed automatically when configured to do so +- if the required Security auditing categories are disabled on an endpoint, the event query may still return no matching events even though the monitored activity occurred + +For example: + +- `4624`, `4625`, `4634`, `4647`, and `4648` generally depend on **Logon/Logoff** auditing +- `4720`, `4729`, `4732`, and `4740` depend on **Account Management** auditing + +Current preflight coverage includes: + +- `Logon` +- `Logoff` +- `Other Logon/Logoff Events` +- `User Account Management` +- `Security Group Management` +- `Account Lockout` + +Optional remediation: + +- when `AutoEnableMissingAuditPolicy = $true`, the script attempts to enable missing subcategories with `auditpol /set` +- this is best suited for Cybertek-managed endpoints where local policy changes are expected and acceptable +- domain Group Policy may still overwrite local audit policy later +- the script logs whether the endpoint was already compliant, remediated successfully, or failed remediation for one or more subcategories + +Future improvement: + +- add a stronger mapping document for exact audit subcategories by event family +- optionally add auto-remediation guidance when auditing is missing + +--- + +## Feature Notes + +### State Tracking + +The `RecordId` state file is what prevents duplicate alerts from recurring scheduled runs. + +### Lookback Window + +The lookback window protects the first run and short scheduling gaps. After the first run, duplicate suppression is primarily handled by the saved `RecordId`. + +### Aggregated Alerts + +The script currently aggregates all newly matched events from a run into one alert body rather than firing a separate Syncro alert for each event. + +--- + +## Known Limitations + +1. The script only sees what Windows Security auditing is configured to log. +2. If the state file is deleted, previously seen events inside the lookback window may alert again. +3. The script monitors only the local Security log, not forwarded events or centralized logs. +4. The initial event set is intentionally focused; many useful Security event IDs are not yet included. +5. Audit-policy remediation can still be overridden later by domain Group Policy or other configuration management. + +--- + +## Future Enhancements + +### Detection Enhancements + +1. Expand audit-policy validation coverage and reporting. +2. Add optional monitoring for privilege escalation and sensitive policy changes. +3. Add per-event include/exclude tuning via Syncro script variables. +4. Add username filtering or allowlists for expected service/admin activity. +5. Add better post-remediation verification after auto-enabling audit policy. + +### Response Enhancements + +1. Open tickets automatically for especially sensitive events. +2. Write summary data into Syncro asset fields. +3. Split high-severity events into their own alert category. + +### Management Enhancements + +1. Add a companion documentation matrix showing which audit policy settings are required. +2. Build separate variants for: + - user sign-in monitoring + - account management monitoring + - high-risk administrative activity + +--- + +## Success Criteria + +This project is successful when: + +1. The script runs repeatedly in Syncro without duplicate alert spam. +2. Interactive and RDP logons are detected reliably using structured `LogonType` filtering. +3. New user creation, group membership changes, lockouts, and failed logons surface as actionable alerts. +4. Quiet endpoints complete cleanly without generating error alerts. + +--- + +## Files + +### `event_log_monitor.ps1` + +Production script for monitoring selected Windows Security audit events in Syncro. + +### `mock_syncro_module.psm1` + +Local testing stub for `Rmm-Alert`, `Close-Rmm-Alert`, `Log-Activity`, and `Set-Asset-Field`. + +### `test_local.ps1` + +Local test runner for executing the script outside Syncro. + +### `project.md` + +This design and scope document. diff --git a/lolrmm/lolrmm_syncro_detector.ps1 b/lolrmm/lolrmm_syncro_detector.ps1 new file mode 100644 index 0000000..e7a5891 --- /dev/null +++ b/lolrmm/lolrmm_syncro_detector.ps1 @@ -0,0 +1,1308 @@ +#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 diff --git a/lolrmm/project.md b/lolrmm/project.md new file mode 100644 index 0000000..9218036 --- /dev/null +++ b/lolrmm/project.md @@ -0,0 +1,490 @@ +# Project: Syncro LOLRMM Detector + +## Summary + +A **single self-contained PowerShell script** that runs on a recurring interval inside **Syncro RMM** and detects **unauthorized living-off-the-land remote management (LOLRMM)** activity on Windows endpoints. + +The detector dynamically pulls the current LOLRMM catalog, inspects the local system for suspicious artifacts across the currently enabled RMM-focused detection layers, compares findings against an internal allowlist, and creates a **Syncro RMM alert** when non-approved remote management tools are detected. + +This project gives Cybertek a lightweight, scalable endpoint detection layer for **unexpected remote access and RMM tooling**, especially for customers that may not have full Defender for Endpoint / SIEM coverage everywhere. + +--- + +## Business Problem + +Attackers increasingly use legitimate remote administration tools instead of obviously malicious malware. + +Examples include: + +- AnyDesk +- RustDesk +- TeamViewer +- ConnectWise ScreenConnect +- LogMeIn / GoToAssist +- Level.io +- RAdmin +- Other tools cataloged by LOLRMM + +These tools are often: + +- digitally signed +- encrypted over the network +- operationally normal-looking +- portable or easy to stage in user-writable paths + +Because of that, they can bypass shallow antivirus-based detections and blend into normal IT activity. + +The true detection goal is **not** "detect every remote tool." + +The true detection goal is: + +> Detect remote management infrastructure that is **not approved** for that customer or device. + +--- + +## Project Goals + +### Primary Goals + +1. Pull LOLRMM data dynamically from the published API at runtime. +2. Detect likely unauthorized RMM artifacts on Windows endpoints across focused LOLRMM-backed detection layers. +3. Generate a Syncro RMM alert automatically when a detection occurs. +4. Keep everything contained in one PowerShell script that can be scheduled through Syncro. +5. Support allowlisting so approved tools do not generate noisy alerts. +6. Protect the integrity of the local feed cache against tampering. +7. Classify detections as NEW or RECURRING to support noise management. + +### Secondary Goals + +1. Write useful context into the alert body so technicians can triage quickly. +2. Optionally write scan results into Syncro Asset Custom Fields. +3. Optionally auto-close the open alert category when subsequent scans are clean. +4. Use a local cache so detection still works if the LOLRMM feed is temporarily unavailable. +5. Detect renamed RMM binaries through PE version resource metadata. +6. Flag detections running from user-writable paths as elevated risk. +7. Keep detections anchored to explicit LOLRMM tool matches rather than broad software inventory heuristics. + +### Non-Goals + +1. Full historical network telemetry. +2. Kernel-level or ETW-based monitoring. +3. Automated containment by default. +4. Per-customer centralized allowlist management (deferred to future phase). +5. File artifact checks from LOLRMM `Artifacts.Disk` (filesystem scans are too expensive for recurring execution). +6. Broad persistence hunting outside clear RMM identification scope. + +--- + +## Why This Matters + +Unauthorized RMM activity is often one of the earliest visible signs of interactive attacker behavior. + +Threat actors commonly install or launch remote management tools to: + +- keep persistence after initial access +- move laterally +- provide operator access to other team members +- re-enter the environment later +- stage for extortion or ransomware operations + +That makes LOLRMM detection a strong **early-warning control**. + +--- + +## Architecture + +### Execution Model + +- **Language:** PowerShell 5.1+ +- **Runtime:** Syncro Windows script runner +- **Schedule:** Recurring via Syncro policy or asset schedule +- **Run Context:** SYSTEM +- **Alert Method:** `Rmm-Alert` + +### Data Source + +- LOLRMM API JSON feed: + - `https://lolrmm.io/api/rmm_tools.json` + +### Local Storage + +| Path | Purpose | +|------|---------| +| `C:\ProgramData\Cybertek\LOLRMM\rmm_tools_cache.json` | Cached LOLRMM feed | +| `C:\ProgramData\Cybertek\LOLRMM\rmm_tools_cache.json.sha256` | SHA-256 hash of cached feed for integrity verification | +| `C:\ProgramData\Cybertek\LOLRMM\last_detections.json` | Prior detection state for NEW vs RECURRING classification | +| `C:\ProgramData\Cybertek\LOLRMM\last_scan_report.txt` | Full untruncated detection report from most recent scan | + +The script prefers the live feed but falls back to cached data if the download fails and the cache is within the configured age limit. The cache directory ACL is hardened on first run to restrict write access to SYSTEM and Administrators only. + +--- + +## Detection Layers + +The current script is intentionally scoped to **RMM identification only**. It favors layers that can be tied back to explicit LOLRMM indicators and avoids broader persistence/inventory checks that created noise during testing. + +### 1. Running Process Detection + +Look for current processes whose names, executable paths, or **PE version resource metadata** match LOLRMM-derived indicators. + +Indicator sources: + +- `Details.PEMetadata.Filename` +- `Details.PEMetadata.OriginalFileName` +- `Details.PEMetadata.InternalName` +- `Details.InstallationPaths` + +**Matching strategy:** +- Process filename and stem are matched using **exact equality** against the indicator set. +- Full executable paths are matched using **path-aware substring** — the indicator must contain a path separator (`\` or `/`) to qualify, preventing short filename stems from spuriously matching unrelated Windows processes. +- PE version metadata (`ProductName`, `FileDescription`) uses **exact equality** against LOLRMM PE metadata values rather than broad substring matching. + +PE version info is read directly from the binary using `FileVersionInfo`. This catches **renamed binaries** where the filename has been changed but the embedded version resource was not stripped. + +Detections from user-writable paths (`%TEMP%`, `%APPDATA%`, `Downloads`, `Desktop`, `C:\Users\*`) are flagged with a `*** USER-WRITABLE PATH ***` marker in the alert body. + +### 2. Service Detection + +Inspect Windows services for service names, display names, and binary paths matching LOLRMM indicators. + +**Matching strategy:** Service name and display name use exact equality matching. Binary path uses path-aware substring matching (same rules as process path above). + +This detects installed RMM agents even when they are not actively being used. + +### 3. Live Network Detection + +Inspect **current established TCP connections** using `Get-NetTCPConnection`, then: + +- map connections to owning process IDs +- look up the process path +- optionally perform reverse DNS lookup on the remote IP +- compare the resolved hostname to LOLRMM domains when reverse DNS is enabled + +**Current default:** reverse DNS is **disabled** to avoid long endpoint-specific timeouts. The layer still evaluates the owning process name and path against LOLRMM indicators. + +**Caveat:** This layer is point-in-time only. It can detect active sessions but will miss short-lived connections that start and stop between script runs. The other layers cover persistence regardless of active session state. + +### 4. Scheduled Task Detection + +Inspect Windows scheduled tasks for action executable paths, arguments, and task names that match LOLRMM indicators. + +**Matching strategy:** Task name and executable filename/stem use exact equality matching. Executable full path and arguments use path-aware substring matching. + +Microsoft built-in task paths (`\Microsoft\Windows\`, `\Microsoft\Office\`, etc.) are excluded to reduce noise. This layer is configurable via `ExcludedTaskPaths`. + +### 5. Registry Artifact Detection + +Perform targeted registry key existence checks using artifact paths from LOLRMM's `Artifacts.Registry` catalog data. + +Only HKLM keys are checked. One detection per LOLRMM tool name is generated to avoid duplicate alerts. + +This layer is lightweight — it performs only the specific key lookups listed in the catalog, not a broad registry scan. + +### Disabled by Default + +The following layers exist in code or earlier design work but are **disabled by default** in the current script to keep output tightly aligned to RMM detection and reduce false positives: + +- Installed software detection +- Startup entry detection + +These may be revisited later as separate scripts or narrower optional modules. + +--- + +## Data Integrity + +### Feed Validation + +After downloading the live feed, the script validates that the parsed JSON contains at least `MinFeedEntryCount` entries (default: 50) before trusting or caching it. This prevents a truncated, empty, or spoofed response from being cached and silently degrading detection. + +### Cache Integrity + +The SHA-256 hash of the cached feed is stored in a companion `.sha256` file. On each cache load, the hash is recomputed and compared. A mismatch aborts the script with an error alert rather than silently operating on tampered data. + +### Cache Directory ACL + +On first run, the script removes inherited permissions from `C:\ProgramData\Cybertek\LOLRMM\` and grants Full Control exclusively to SYSTEM and the Administrators group. This prevents a low-privilege process from modifying the cached feed or state files. + +--- + +## Alerting Strategy + +### Alert Category + +- `unauthorized_rmm_detected` + +### Alert Contents + +The alert body includes: + +- host/computer name +- scan timestamp +- feed source (live or cache) +- total detection count with NEW / RECURRING breakdown +- per-detection: layer, NEW/RECURRING status, matched LOLRMM tool name +- process name, PID, executable path +- PE version metadata (ProductName, CompanyName) when available +- user-writable path warning when applicable +- publisher/signature info +- service state and start mode +- scheduled task path and executable +- registry artifact key path +- remote IP, port, and reverse-DNS hostname for network matches when reverse DNS is enabled +- matched-on reason for each detection + +### NEW vs RECURRING Classification + +Detection state is persisted between runs in `last_detections.json`. Each detection is classified as: + +- **NEW** — not seen on the previous scan +- **RECURRING** — seen on one or more prior scans + +Both are included in alerts by default. Setting `SuppressRecurringDetections = $true` limits alerts to new findings only, which significantly reduces noise on endpoints with a known persistent tool under investigation. + +### Dedupe Behavior + +Syncro de-dupes by alert category per asset, so the script consistently uses the same alert category for this detector. + +### Optional Cleanup + +When a subsequent scan is clean, the script closes the alert category automatically if `CloseAlertWhenClean = $true` (the default). + +--- + +## Allowlist Design + +The allowlist is what makes this operationally useful rather than noisy. + +### Allowlist Dimensions + +- **Approved domains** — suppress network matches to these domains +- **Approved process names** — suppress process/service/task matches by name +- **Approved path patterns** — suppress matches from these path patterns (wildcard) +- **Approved publishers** — suppress matches where the Authenticode CN field matches + +Publisher matching extracts the `CN=` field from the full Authenticode subject string for a tighter comparison, rather than a loose substring match against the entire subject. + +### Current Defaults + +- Splashtop (process names, path pattern, publisher) +- Syncro (name, path pattern, domain) + +### Future Improvement + +Move allowlists out of the script and into one of: + +- Syncro script variables +- customer-specific custom fields +- hosted JSON config +- internal API + +--- + +## Feature Flags + +All major detection layers and behaviors are independently toggleable in `$Config`: + +| Flag | Default | Purpose | +|------|---------|---------| +| `EnableScheduledTaskDetection` | `$true` | Scheduled task layer | +| `EnableInstalledSoftwareDetection` | `$false` | Uninstall-registry software inventory layer | +| `EnableStartupEntryDetection` | `$false` | Run/RunOnce startup layer | +| `EnableRegistryArtifacts` | `$true` | Registry artifact layer | +| `EnablePEVersionCheck` | `$true` | Renamed binary detection via PE metadata | +| `SuppressRecurringDetections` | `$false` | Only alert on NEW detections | +| `HardenCacheDirectoryAcl` | `$true` | Restrict cache directory permissions | +| `CloseAlertWhenClean` | `$true` | Auto-close alert on clean scan | +| `SetAssetFields` | `$false` | Write results to Syncro Asset Custom Fields | +| `ResolveReverseDns` | `$false` | Reverse DNS for network connections | +| `ResolveReverseDnsPrivateIPs` | `$false` | Also resolve private/RFC1918 IPs | + +--- + +## Script Outputs + +### Success / Clean + +- exit code `0` +- no unauthorized matches +- state file updated +- optionally closes prior alert category + +### Detection Found + +- exit code `0` (Syncro handles alerting via `Rmm-Alert`; non-zero exit is not needed) +- creates/updates Syncro RMM alert with full triage context +- writes full untruncated report to `C:\ProgramData\Cybertek\LOLRMM\last_scan_report.txt` for technician review +- alert body is truncated at `MaxAlertBodyLength` characters with a note pointing to the report file +- persists detection state for NEW/RECURRING tracking +- optionally writes asset fields and activity log entry + +### Script Error + +- exit code `2` +- creates `unauthorized_rmm_detector_error` alert category + +--- + +## Deployment Plan + +### Phase 1: Internal Validation + +1. Import script into Syncro. +2. Run manually on internal lab/test endpoints. +3. Validate: + - LOLRMM feed download and validation + - cache fallback and integrity check + - cache directory ACL hardening + - process matches (name, path, PE version info) + - service matches + - scheduled task matches + - registry artifact matches + - network matches + - NEW vs RECURRING classification across consecutive runs + - alert creation and body content + - alert closing behavior on clean scan +4. Intentionally launch a known non-approved tool in a test environment and verify detection across all applicable layers. +5. Rename a known RMM binary and verify PE version info detection fires. + +### Phase 2: Controlled Pilot + +1. Deploy to Cybertek-managed internal endpoints. +2. Schedule every 30–60 minutes. +3. Review noise and false positives. +4. Expand allowlist as needed. +5. Evaluate whether `SuppressRecurringDetections` should be enabled for steady-state operation. + +### Phase 3: High-Risk Customer Rollout + +1. Roll out to compliance-sensitive and higher-risk customers. +2. Consider 15-minute interval only if script runtime remains consistently low. +3. Pair alerts with automated remediation or ticketing later if desired. + +--- + +## Scheduling Guidance + +Recommended first-pass schedule: + +- **High-risk endpoints:** every 15–30 minutes +- **Most managed workstations:** every 30–60 minutes +- **Lower-risk / resource-sensitive endpoints:** hourly + +The script is designed to remain lightweight. The most expensive operations (process enumeration, PE version reads, scheduled task enumeration) complete in seconds on typical endpoints. + +--- + +## Known Limitations + +1. **Network checks are not historical.** + - They only see established sockets at scan time. + +2. **Reverse DNS is disabled by default.** + - It caused endpoint-specific timeouts during testing. + - It can be re-enabled later if domain-level enrichment is worth the runtime cost in a given environment. + +3. **Community-driven catalog data can be incomplete.** + - Some LOLRMM tools have richer metadata than others. + +4. **PE version info can be stripped or spoofed.** + - Sophisticated attackers can clear or alter the version resource block. + - This is an additional detection layer, not a guarantee. + +5. **Registry artifacts only cover HKLM when running as SYSTEM.** + +6. **Scheduled task exclusions are path-based.** + - A malicious task placed under `\Microsoft\Windows\` would be skipped. This is an accepted trade-off for noise reduction. + +7. **Signature trust is contextual.** + - Signed software can still be abused. Publisher allowlisting is a convenience, not a security control. + +8. **Exact name matching may miss obfuscated RMM filenames.** + - If an attacker renames an RMM binary to something not in the LOLRMM catalog's filename indicators, the process/service name layers will not match. The PE version metadata secondary check partially compensates for this. + +9. **The detector is only as precise as the LOLRMM catalog.** + - The script now tries to report only matches that map back to an explicit LOLRMM tool. + - If the upstream catalog is broad or incomplete, detections will inherit those limitations. + +--- + +## Future Enhancements + +### Detection Enhancements + +1. File artifact checks from LOLRMM `Artifacts.Disk` (low priority — expensive on large disks). +2. Enumerate loaded user registry hives (`HKU:\`) for HKCU registry artifact checks. +3. WMI subscription persistence detection. +4. Correlate detections across layers for a simple confidence score. +5. Detect processes with no window title / hidden windows as an additional risk signal. + +### Response Enhancements + +1. Auto-create Syncro ticket on high-confidence or NEW detections. +2. Auto-run remediation script on specific tools. +3. Kill specific unauthorized processes. +4. Notify a Teams/webhook channel. + +### Management Enhancements + +1. Centralize allowlists outside the script (Syncro script variables, hosted JSON, or internal API). +2. Build customer-specific policy profiles. +3. Write summary status into Syncro Asset Custom Fields for saved searches and reporting. +4. Add a dashboard/reporting layer for all LOLRMM detections across the customer base. + +### Variant Roadmap + +Two script variants are planned: + +1. **MIT / Cybertek-managed clients** + - Assumes no non-approved RMM tools should exist. + - May take automated remediation steps after high-confidence detection. + - Will require a separate remediation map per tool rather than a generic uninstall workflow. + +2. **Public / general client version** + - Will remain detection-first. + - Should prompt the user to acknowledge the detected remote access tool and optionally request Cybertek review / ticket submission. + - This interactive workflow is planned for a future iteration and is not part of the current script. + +--- + +## Success Criteria + +This project is successful when: + +1. The script runs repeatedly inside Syncro without manual intervention. +2. The LOLRMM feed is pulled dynamically or loaded from a verified cache. +3. Unauthorized RMM tools generate actionable Syncro alerts across the enabled RMM-focused detection layers. +4. Renamed RMM binaries are caught via PE version metadata. +5. Approved tooling does not generate frequent false positives. +6. Technicians can triage directly from alert content without opening the endpoint first. +7. Recurring detections are distinguishable from new ones in the alert body. + +--- + +## Files + +### `lolrmm_syncro_detector.ps1` +Production-ready PowerShell detector for Syncro. Focused LOLRMM-backed detection across processes, services, network connections, scheduled tasks, and registry artifacts; feed integrity validation; cache ACL hardening; PE version metadata checks; user-writable path flagging; NEW/RECURRING state tracking; full report file output; and conservative exact/path-aware matching tuned to reduce false positives. + +### `mock_syncro_module.psm1` +Local testing stub that implements `Rmm-Alert`, `Close-Rmm-Alert`, `Log-Activity`, and `Set-Asset-Field` as console-printing mock functions. Allows local test runs without a live Syncro connection. + +### `test_local.ps1` +Local test runner. Sets `$env:SyncroModule` to the mock module path and launches the detector in a child process so `exit` calls do not close the test session. + +### `project.md` +This planning and design file. + +--- + +## Suggested Immediate Next Steps + +1. Import the script into Syncro as a PowerShell script. +2. Test manually on an internal workstation — verify feed download, cache write, and ACL hardening. +3. Install a known non-approved RMM tool in a lab environment. +4. Verify detections fire across process, service, scheduled task, network, and registry artifact layers. +5. Rename the tool binary and verify PE version info detection catches it. +6. Run the script a second time and verify the detection is classified as RECURRING. +7. Uninstall the tool and verify the third run is clean and the alert closes. +8. Tune the allowlist before broad deployment. +9. Decide whether `SuppressRecurringDetections = $true` fits the operational workflow. diff --git a/network_traffic_monitor/network_traffic_monitor.ps1 b/network_traffic_monitor/network_traffic_monitor.ps1 new file mode 100644 index 0000000..c497885 --- /dev/null +++ b/network_traffic_monitor/network_traffic_monitor.ps1 @@ -0,0 +1,1070 @@ +#requires -Version 5.1 +<# + ______ __ __ __ + / ____/_ __/ /_ ___ _____/ /____ / /__ + / / / / / / __ \/ _ \/ ___/ __/ _ \/ //_/ + / /___/ /_/ / /_/ / __/ / / /_/ __/ ,< + \____/\__, /_.___/\___/_/ \__/\___/_/|_| + /____/ + + .SYNOPSIS + CYBERTEK Syncro network traffic investigation monitor. + + .DESCRIPTION + Collects active TCP connections with process attribution and DNS cache + entries, then performs multi-layer behavioral analysis to surface + suspicious activity. Results accumulate between hourly runs via a local + state file. + + Detection layers: + 1. Active connections - Get-NetTCPConnection + process attribution + 2. DNS cache - Get-DnsClientCache monitored for anomalies + 3. Threat intel lists - IP, CIDR, and domain blocklists pulled from + github.com/cybertek605/cybertek-threat-intel + (Feodo Tracker, Emerging Threats, Spamhaus + DROP/EDROP, URLhaus) + 4. AbuseIPDB - Per-IP reputation enrichment (API, cached 24h) + 5. Process correlation - LOLBins and scripting engines with external + connections flagged as Critical + 6. Port analysis - Known-bad ports flagged as Warning + 7. Exfil indicators - High outbound connection volume (same + process + same external destination) + 8. DNS anomalies - High-entropy domains, suspicious TLDs, domains + resolving to blocked IPs + + .NOTES + Script Name : network_traffic_monitor.ps1 + Author : Cybertek + Maintainer : Cybertek / Claude-assisted implementation + Version : 2026-03-15 + Run Context : SYSTEM via Syncro RMM (hourly schedule) + Purpose : Continuous network traffic investigation with accumulation. + Notes : AbuseIPDB API key must be set as a Syncro Script Variable + named 'abuseipdb_api_key'. Without it, API enrichment is + skipped and local heuristics + blocklists still run. +#> + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +Import-Module $env:SyncroModule + +# ── Syncro Script Variables ─────────────────────────────────────────────────── +# Configure in Syncro's script editor under Script Variables: +# +# Variable Name Description +# ------------------- ------------------------------------------------------- +# abuseipdb_api_key Your AbuseIPDB v2 API key +# +function Resolve-ScriptVar { + param([string]$Name, [string]$Default = '') + $v = Get-Variable -Name $Name -ErrorAction SilentlyContinue + if ($null -eq $v -or [string]::IsNullOrWhiteSpace([string]$v.Value)) { return $Default } + return ([string]$v.Value).Trim() +} + +# ── Configuration ───────────────────────────────────────────────────────────── +$Config = [ordered]@{ + # Syncro + AlertCategoryCritical = 'network_traffic_critical' + AlertCategoryWarning = 'network_traffic_warning' + AlertCategoryError = 'network_traffic_monitor_error' + LogEventName = 'Network Traffic Monitor' + + # Paths + DataDir = 'C:\ProgramData\Cybertek\NetworkTrafficMonitor' + StatePath = 'C:\ProgramData\Cybertek\NetworkTrafficMonitor\state.json' + ReportPath = 'C:\ProgramData\Cybertek\NetworkTrafficMonitor\last_report.txt' + TiScorePath = 'C:\ProgramData\Cybertek\NetworkTrafficMonitor\ti_ip_scores.txt' + TiCidrPath = 'C:\ProgramData\Cybertek\NetworkTrafficMonitor\ti_cidr_blocklist.txt' + TiDomainPath = 'C:\ProgramData\Cybertek\NetworkTrafficMonitor\ti_domain_blocklist.txt' + + # Threat intel GitHub source (cybertek605/cybertek-threat-intel) + TiBaseUrl = 'https://raw.githubusercontent.com/cybertek605/cybertek-threat-intel/main/lists' + TiScoreRefreshHours = 12 # ip_scores.txt - contains all IP feeds + cross-feed scores + TiDomainRefreshHours = 24 + + # IP score thresholds (score = number of source lists that flagged the IP) + # score 1 = single source → Warning + # score 2+ = multi-source → Critical (independent feeds agree) + TiIpWarnThreshold = 1 + TiIpCritThreshold = 2 + + # AbuseIPDB (per-IP reputation API - enriches IPs not already on blocklists) + # TODO: When AbuseIPDB enterprise is purchased, the bulk blacklist download + # is handled in aggregate.ps1. Update AbuseIpDbMaxPerRun to 0 here to + # disable per-run API calls once the bulk list covers the same data. + AbuseIpDbApiKey = (Resolve-ScriptVar -Name 'abuseipdb_api_key') + AbuseIpDbApiUrl = 'https://api.abuseipdb.com/api/v2/check' + AbuseIpDbCacheTtlHours = 24 + AbuseIpDbMaxPerRun = 20 # Free tier: 1,000/day; 20/run at hourly = 480/day max + AbuseIpDbWarnThreshold = 25 # Score >= this → Warning + AbuseIpDbCritThreshold = 75 # Score >= this → Critical + + # Behavioral thresholds + ExfilMinConnections = 10 # Concurrent conns from one process to one external IP + HighEntropyThreshold = 3.8 # Shannon entropy above this = DGA indicator + ConnectionHistoryPruneDays = 30 + + # Finding deduplication / suppression + # A finding is suppressed (no re-alert) until either: + # a) it disappears and reappears, or + # b) it has persisted longer than FindingSuppressionHours since it was last alerted + FindingSuppressionHours = 24 # Hours before re-alerting on the same persistent finding + FindingPruneDays = 7 # Days to retain a finding in history after last seen + + # Alert + MaxAlertBodyLength = 7000 + CloseAlertWhenClean = $false +} + +# ── Port sets ───────────────────────────────────────────────────────────────── +$SuspiciousPorts = [System.Collections.Generic.HashSet[int]]@( + 23, # Telnet + 1080, # SOCKS proxy + 1234, 1337, 2222, # Common RAT / abused dev ports + 4444, 4445, 4446, # Metasploit defaults + 5555, # ADB / common RAT + 6666, 6667, 6668, 6669, # IRC (frequently used for C2) + 7777, 8888, 9001, 9002, # Common C2 / Tor + 12345, 31337, # Classic + 50050, # Cobalt Strike Team Server default + 54321, 65535 # High-port reverse shells +) + +$CommonOutboundPorts = [System.Collections.Generic.HashSet[int]]@( + 21, 22, 25, 53, 80, 110, 143, 443, 465, 587, + 993, 995, 3389, 5985, 5986, 8080, 8443 +) + +$ForbiddenExternalProcesses = [System.Collections.Generic.HashSet[string]]@( + 'lsass', 'csrss', 'smss', 'wininit', 'winlogon', 'services' +) + +$SuspiciousProcesses = [System.Collections.Generic.HashSet[string]]@( + 'powershell', 'pwsh', 'cmd', 'wscript', 'cscript', + 'mshta', 'regsvr32', 'rundll32', 'certutil', 'bitsadmin', + 'msiexec', 'installutil', 'regasm', 'regsvcs', + 'msbuild', 'cmstp', 'wmic', 'forfiles', 'pcalua' +) + +$SuspiciousTlds = [System.Collections.Generic.HashSet[string]]@( + '.xyz', '.top', '.tk', '.ml', '.ga', '.cf', '.gq', + '.pw', '.cc', '.work', '.click', '.link', '.loan', + '.win', '.download', '.bid', '.party', '.ru', '.cn' +) + +$InternalPrefixes = @( + '10.', '172.16.', '172.17.', '172.18.', '172.19.', + '172.20.', '172.21.', '172.22.', '172.23.', '172.24.', + '172.25.', '172.26.', '172.27.', '172.28.', '172.29.', + '172.30.', '172.31.', '192.168.', '127.', '169.254.' +) + +# IPs allowlisted from all detection (treated same as internal). +# Add CIDRs/prefixes here for known-good infrastructure that would otherwise +# trigger LOLBin, blocklist, or port findings (e.g. the script's own traffic). +$IpAllowlistPrefixes = @( + '185.199.108.', '185.199.109.', '185.199.110.', '185.199.111.' # GitHub CDN (raw.githubusercontent.com) +) + +# Domains allowlisted from DNS anomaly detection. +# Domains listed here are skipped before the URLhaus blocklist check. +$DomainAllowlist = [System.Collections.Generic.HashSet[string]]@( + 'raw.githubusercontent.com' # GitHub raw content CDN — used by this script to download threat intel +) + +# ── Helpers ─────────────────────────────────────────────────────────────────── +function Write-Log { + param([string]$Message) + [Console]::WriteLine("[NETMON] $Message") +} + +function Ensure-Directory { + param([string]$Path) + if (-not (Test-Path -LiteralPath $Path -ErrorAction SilentlyContinue)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Ensure-ParentDirectory { + param([string]$Path) + $parent = Split-Path -Path $Path -Parent + if (-not [string]::IsNullOrWhiteSpace($parent)) { Ensure-Directory -Path $parent } +} + +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 Test-IsInternalAddress { + param([string]$Address) + if ([string]::IsNullOrWhiteSpace($Address)) { return $true } + foreach ($prefix in $InternalPrefixes) { + if ($Address.StartsWith($prefix)) { return $true } + } + foreach ($prefix in $IpAllowlistPrefixes) { + if ($Address.StartsWith($prefix)) { return $true } + } + if ($Address -eq '::1' -or $Address.StartsWith('fe80')) { return $true } + return $false +} + +function Test-IsRoutableAddress { + param([string]$Address) + if ([string]::IsNullOrWhiteSpace($Address)) { return $false } + if ($Address -eq '0.0.0.0' -or $Address -eq '::') { return $false } + return -not (Test-IsInternalAddress -Address $Address) +} + +function Test-IpInCidr { + param([string]$IpAddress, [string]$Cidr) + try { + $parts = $Cidr -split '/' + $prefix = [int]$parts[1] + $mask = if ($prefix -eq 0) { [uint32]0 } else { [uint32](0xFFFFFFFF) -shl (32 - $prefix) } + + $netBytes = [System.Net.IPAddress]::Parse($parts[0]).GetAddressBytes() + $ipBytes = [System.Net.IPAddress]::Parse($IpAddress).GetAddressBytes() + if ($netBytes.Length -ne 4 -or $ipBytes.Length -ne 4) { return $false } + + [Array]::Reverse($netBytes) + [Array]::Reverse($ipBytes) + $netUint = [System.BitConverter]::ToUInt32($netBytes, 0) + $ipUint = [System.BitConverter]::ToUInt32($ipBytes, 0) + return ($ipUint -band $mask) -eq ($netUint -band $mask) + } + catch { return $false } +} + +function Get-DomainEntropy { + param([string]$Domain) + if ([string]::IsNullOrWhiteSpace($Domain)) { return 0.0 } + $label = $Domain.Split('.')[0].ToLowerInvariant() + if ($label.Length -lt 6) { return 0.0 } + $freq = @{} + foreach ($c in $label.ToCharArray()) { + $k = [string]$c + if ($freq.ContainsKey($k)) { $freq[$k]++ } else { $freq[$k] = 1 } + } + $entropy = 0.0 + foreach ($count in $freq.Values) { + $p = $count / $label.Length + $entropy -= $p * [Math]::Log($p, 2) + } + return [Math]::Round($entropy, 4) +} + +function Test-IsSuspiciousDomain { + param([string]$Domain) + if ([string]::IsNullOrWhiteSpace($Domain)) { return $false } + $lower = $Domain.ToLowerInvariant() + foreach ($tld in $SuspiciousTlds) { + if ($lower.EndsWith($tld)) { return $true } + } + return $false +} + +# ── State management ────────────────────────────────────────────────────────── +function Load-State { + param([string]$Path) + $empty = [PSCustomObject]@{ + ConnectionHistory = [PSCustomObject]@{} + AbuseIpDbCache = [PSCustomObject]@{} + DnsHistory = [PSCustomObject]@{} + FindingHistory = [PSCustomObject]@{} + LastRun = $null + } + if (-not (Test-Path -LiteralPath $Path -ErrorAction SilentlyContinue)) { return $empty } + try { + $loaded = (Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json) + # Upgrade: add FindingHistory if missing (state files created before this feature) + if (-not $loaded.PSObject.Properties['FindingHistory']) { + $loaded | Add-Member -NotePropertyName 'FindingHistory' -NotePropertyValue ([PSCustomObject]@{}) + } + return $loaded + } + catch { + Write-Warning "Could not load state: $($_.Exception.Message)" + return $empty + } +} + +function Save-State { + param( + [string]$Path, + [hashtable]$ConnectionHistory, + [hashtable]$AbuseIpDbCache, + [hashtable]$DnsHistory, + [hashtable]$FindingHistory + ) + try { + Ensure-ParentDirectory -Path $Path + ([PSCustomObject]@{ + ConnectionHistory = $ConnectionHistory + AbuseIpDbCache = $AbuseIpDbCache + DnsHistory = $DnsHistory + FindingHistory = $FindingHistory + LastRun = (Get-Date -Format s) + } | ConvertTo-Json -Depth 6) | Set-Content -LiteralPath $Path -Encoding UTF8 -Force + } + catch { Write-Warning "Could not save state: $($_.Exception.Message)" } +} + +function ConvertTo-Hashtable { + param([AllowNull()] [object]$PsObj) + $ht = @{} + if ($null -eq $PsObj) { return $ht } + foreach ($prop in $PsObj.PSObject.Properties) { $ht[$prop.Name] = $prop.Value } + return $ht +} + +function Prune-ConnectionHistory { + param([hashtable]$History, [int]$MaxDays) + $cutoff = (Get-Date).AddDays(-$MaxDays) + $toRemove = [System.Collections.Generic.List[string]]::new() + foreach ($key in $History.Keys) { + $lastSeen = $null + try { + $entry = $History[$key] + if ($entry.PSObject.Properties['LastSeen']) { $lastSeen = [datetime]$entry.LastSeen } + } + catch {} + if ($null -eq $lastSeen -or $lastSeen -lt $cutoff) { $toRemove.Add($key) } + } + foreach ($key in $toRemove) { $History.Remove($key) } + return $toRemove.Count +} + +# ── Finding deduplication ───────────────────────────────────────────────────── +function Get-FindingKey { + param([object]$Finding) + $type = $Finding.Type + $domain = if ($Finding.PSObject.Properties['Domain'] -and $Finding.Domain) { $Finding.Domain.ToLowerInvariant() } else { '' } + if ($domain) { return "$type|$domain" } + $proc = if ($Finding.PSObject.Properties['ProcessName'] -and $Finding.ProcessName) { $Finding.ProcessName.ToLowerInvariant() } else { '' } + $remote = if ($Finding.PSObject.Properties['RemoteAddress'] -and $Finding.RemoteAddress) { $Finding.RemoteAddress } else { '' } + $port = if ($Finding.PSObject.Properties['RemotePort'] -and $Finding.RemotePort) { [string]$Finding.RemotePort } else { '' } + return "$type|$proc|$remote|$port" +} + +function Update-FindingHistory { + # Annotates each finding in-place with IsNew ($true = fire alert) and FirstSeen. + # A finding is IsNew when: + # - It has never been seen before, OR + # - Its suppression window (FindingSuppressionHours) has elapsed since AlertedAt + # Prunes findings not seen within FindingPruneDays. + param( + [object[]]$CurrentFindings, + [hashtable]$History, + [int]$SuppressionHours, + [int]$PruneDays + ) + $now = Get-Date + $nowStr = Get-Date -Format s + + foreach ($f in $CurrentFindings) { + $key = Get-FindingKey -Finding $f + $isNew = $false + + if (-not $History.ContainsKey($key)) { + $isNew = $true + $History[$key] = [PSCustomObject]@{ + FirstSeen = $nowStr + LastSeen = $nowStr + AlertedAt = $nowStr + Severity = $f.Severity + Type = $f.Type + } + } + else { + $alertedAt = $null + try { $alertedAt = [datetime]$History[$key].AlertedAt } catch {} + if ($null -eq $alertedAt -or ($now - $alertedAt).TotalHours -ge $SuppressionHours) { + $isNew = $true + $History[$key].AlertedAt = $nowStr + } + $History[$key].LastSeen = $nowStr + } + + $firstSeen = $null + try { $firstSeen = $History[$key].FirstSeen } catch {} + $f | Add-Member -NotePropertyName 'IsNew' -NotePropertyValue $isNew -Force + $f | Add-Member -NotePropertyName 'FirstSeen' -NotePropertyValue $firstSeen -Force + } + + # Prune stale entries + $cutoff = $now.AddDays(-$PruneDays) + $toRemove = [System.Collections.Generic.List[string]]::new() + foreach ($key in $History.Keys) { + $lastSeen = $null + try { $lastSeen = [datetime]$History[$key].LastSeen } catch {} + if ($null -eq $lastSeen -or $lastSeen -lt $cutoff) { $toRemove.Add($key) } + } + foreach ($key in $toRemove) { $History.Remove($key) } +} + +# ── Threat intel list loading ───────────────────────────────────────────────── +function Get-CachedList { + param( + [string]$LocalPath, + [string]$RemoteUrl, + [int]$RefreshHours, + [string]$ListName + ) + $needsRefresh = $true + if (Test-Path -LiteralPath $LocalPath -ErrorAction SilentlyContinue) { + $ageHours = ((Get-Date) - (Get-Item -LiteralPath $LocalPath).LastWriteTime).TotalHours + if ($ageHours -lt $RefreshHours) { + $needsRefresh = $false + Write-Log "$ListName cache is fresh ($([math]::Round($ageHours,1))h old)." + } + } + + if ($needsRefresh) { + Write-Log "Downloading $ListName..." + try { + $content = (Invoke-WebRequest -Uri $RemoteUrl -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop).Content + Ensure-ParentDirectory -Path $LocalPath + Set-Content -LiteralPath $LocalPath -Value $content -Encoding UTF8 -Force + Write-Log "$ListName downloaded and cached." + } + catch { + Write-Warning "$ListName download failed: $($_.Exception.Message)" + if (-not (Test-Path -LiteralPath $LocalPath -ErrorAction SilentlyContinue)) { return @() } + Write-Log "Using stale $ListName cache." + } + } + + try { + return @(Get-Content -LiteralPath $LocalPath -ErrorAction Stop | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and -not $_.TrimStart().StartsWith('#') }) + } + catch { + Write-Warning "Could not read ${ListName}: $($_.Exception.Message)" + return @() + } +} + +function Load-ThreatIntelLists { + Write-Log 'Loading threat intel lists...' + + # ip_scores.txt - tab-separated "scoreip"; score = number of source feeds + $scoreLines = @(Get-CachedList -LocalPath $Config.TiScorePath ` + -RemoteUrl "$($Config.TiBaseUrl)/ip_scores.txt" ` + -RefreshHours $Config.TiScoreRefreshHours -ListName 'IP score list') + + $cidrLines = @(Get-CachedList -LocalPath $Config.TiCidrPath ` + -RemoteUrl "$($Config.TiBaseUrl)/cidr_blocklist.txt" ` + -RefreshHours $Config.TiDomainRefreshHours -ListName 'CIDR blocklist') + + $domainLines = @(Get-CachedList -LocalPath $Config.TiDomainPath ` + -RemoteUrl "$($Config.TiBaseUrl)/domain_blocklist.txt" ` + -RefreshHours $Config.TiDomainRefreshHours -ListName 'Domain blocklist') + + # Build IP score map: IP -> int score + $ipScoreMap = @{} + foreach ($line in $scoreLines) { + $parts = $line -split "`t" + if ($parts.Count -lt 2) { continue } + $score = 0 + $ip = $parts[1].Trim() + if ([int]::TryParse($parts[0].Trim(), [ref]$score) -and -not [string]::IsNullOrWhiteSpace($ip)) { + $ipScoreMap[$ip] = $score + } + } + + $domainSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($d in $domainLines) { [void]$domainSet.Add($d.Trim().ToLowerInvariant()) } + + $multiSourceCount = ($ipScoreMap.Values | Where-Object { $_ -ge $Config.TiIpCritThreshold } | Measure-Object).Count + Write-Log "Threat intel loaded - IPs: $($ipScoreMap.Count) ($multiSourceCount multi-source) | CIDRs: $($cidrLines.Count) | Domains: $($domainSet.Count)" + + return [PSCustomObject]@{ + IpScoreMap = $ipScoreMap + CidrList = $cidrLines + DomainSet = $domainSet + } +} + +# ── AbuseIPDB ───────────────────────────────────────────────────────────────── +function Get-AbuseIpDbRating { + param([string]$IpAddress, [string]$ApiKey, [string]$ApiUrl) + try { + $uri = "${ApiUrl}?ipAddress=$([Uri]::EscapeDataString($IpAddress))&maxAgeInDays=90&verbose" + $response = Invoke-RestMethod -Uri $uri -Method Get -TimeoutSec 15 -ErrorAction Stop ` + -Headers @{ Key = $ApiKey; Accept = 'application/json' } + + $score = 0; $reports = 0; $country = ''; $isp = '' + if ($response.PSObject.Properties['data']) { + $d = $response.data + if ($d.PSObject.Properties['abuseConfidenceScore']) { $score = [int]$d.abuseConfidenceScore } + if ($d.PSObject.Properties['totalReports']) { $reports = [int]$d.totalReports } + if ($d.PSObject.Properties['countryCode']) { $country = [string]$d.countryCode } + if ($d.PSObject.Properties['isp']) { $isp = [string]$d.isp } + } + return [PSCustomObject]@{ + Score = $score + TotalReports = $reports + Country = $country + ISP = $isp + CheckedAt = (Get-Date -Format s) + Error = $null + } + } + catch { + return [PSCustomObject]@{ Score = -1; CheckedAt = (Get-Date -Format s); Error = $_.Exception.Message } + } +} + +function Update-AbuseIpDbCache { + param([string[]]$IpAddresses, [hashtable]$Cache) + if ([string]::IsNullOrWhiteSpace($Config.AbuseIpDbApiKey)) { return } + $now = Get-Date; $checked = 0 + foreach ($ip in $IpAddresses) { + if ($checked -ge $Config.AbuseIpDbMaxPerRun) { break } + if ($Cache.ContainsKey($ip)) { + $checkedAt = $null + try { $checkedAt = [datetime]$Cache[$ip].CheckedAt } catch {} + if ($checkedAt -and ($now - $checkedAt).TotalHours -lt $Config.AbuseIpDbCacheTtlHours) { continue } + } + Write-Log "AbuseIPDB lookup: $ip" + $Cache[$ip] = Get-AbuseIpDbRating -IpAddress $ip -ApiKey $Config.AbuseIpDbApiKey -ApiUrl $Config.AbuseIpDbApiUrl + $checked++ + Start-Sleep -Milliseconds 300 + } +} + +# ── Data collection ─────────────────────────────────────────────────────────── +function Get-ProcessMap { + $map = @{} + try { Get-Process -ErrorAction SilentlyContinue | ForEach-Object { $map[[int]$_.Id] = $_.ProcessName } } + catch {} + return $map +} + +function Get-ActiveConnections { + param([hashtable]$ProcessMap) + $list = [System.Collections.Generic.List[object]]::new() + try { + foreach ($conn in @(Get-NetTCPConnection -ErrorAction Stop)) { + $remote = [string]$conn.RemoteAddress + $rPort = [int]$conn.RemotePort + if ([string]::IsNullOrWhiteSpace($remote) -or $remote -eq '0.0.0.0' -or $remote -eq '::') { continue } + if ($rPort -eq 0) { continue } + $connPid = [int]$conn.OwningProcess + $proc = if ($ProcessMap.ContainsKey($connPid)) { $ProcessMap[$connPid] } else { 'Unknown' } + $list.Add([PSCustomObject]@{ + RemoteAddress = $remote + RemotePort = $rPort + LocalAddress = [string]$conn.LocalAddress + LocalPort = [int]$conn.LocalPort + State = [string]$conn.State + PID = $connPid + ProcessName = $proc + IsExternal = (Test-IsRoutableAddress -Address $remote) + Timestamp = (Get-Date -Format s) + }) + } + } + catch { Write-Warning "Get-NetTCPConnection failed: $($_.Exception.Message)" } + return $list +} + +function Get-DnsCacheEntries { + $list = [System.Collections.Generic.List[object]]::new() + try { + foreach ($entry in @(Get-DnsClientCache -ErrorAction Stop)) { + $name = [string]$entry.Entry + if ([string]::IsNullOrWhiteSpace($name)) { continue } + $list.Add([PSCustomObject]@{ + Name = $name.ToLowerInvariant().TrimEnd('.') + Data = [string]$entry.Data + Type = [string]$entry.Type + TimeToLive = [int]$entry.TimeToLive + }) + } + } + catch { Write-Warning "Get-DnsClientCache failed: $($_.Exception.Message)" } + return $list +} + +# ── Analysis ────────────────────────────────────────────────────────────────── +function Find-BlocklistHits { + param([object[]]$Connections, [object]$ThreatIntel, [hashtable]$AbuseCache) + $findings = [System.Collections.Generic.List[object]]::new() + $seenIps = [System.Collections.Generic.HashSet[string]]::new() + + foreach ($conn in ($Connections | Where-Object { $_.IsExternal })) { + $ip = $conn.RemoteAddress + if (-not $seenIps.Add($ip)) { continue } + + # Scored IP blocklist - severity based on cross-feed consensus + if ($ThreatIntel.IpScoreMap.ContainsKey($ip)) { + $feedScore = [int]$ThreatIntel.IpScoreMap[$ip] + $severity = if ($feedScore -ge $Config.TiIpCritThreshold) { 'Critical' } else { 'Warning' } + $scoreNote = if ($feedScore -ge $Config.TiIpCritThreshold) { + "MULTI-SOURCE ($feedScore independent feeds) - high confidence malicious" + } else { + "single-source flagged (score $feedScore)" + } + $findings.Add([PSCustomObject]@{ + Type = 'BlocklistedIP' + Severity = $severity + RemoteAddress = $ip + RemotePort = $conn.RemotePort + ProcessName = $conn.ProcessName + PID = $conn.PID + Detail = "Connection to $ip - $scoreNote" + }) + continue + } + + # CIDR range check (Spamhaus DROP/EDROP) + foreach ($cidr in $ThreatIntel.CidrList) { + if (Test-IpInCidr -IpAddress $ip -Cidr $cidr) { + $findings.Add([PSCustomObject]@{ + Type = 'BlocklistedCIDR' + Severity = 'Critical' + RemoteAddress = $ip + RemotePort = $conn.RemotePort + ProcessName = $conn.ProcessName + PID = $conn.PID + Detail = "Connection to $ip - IP falls within Spamhaus DROP/EDROP range $cidr" + }) + break + } + } + + # AbuseIPDB cache check + if ($AbuseCache.ContainsKey($ip)) { + $score = -1 + try { $score = [int]$AbuseCache[$ip].Score } catch {} + $reports = 0; $isp = ''; $country = '' + try { $reports = [int]$AbuseCache[$ip].TotalReports } catch {} + try { $isp = [string]$AbuseCache[$ip].ISP } catch {} + try { $country = [string]$AbuseCache[$ip].Country } catch {} + + if ($score -ge $Config.AbuseIpDbCritThreshold) { + $findings.Add([PSCustomObject]@{ + Type = 'AbuseIPDB_Critical' + Severity = 'Critical' + RemoteAddress = $ip + RemotePort = $conn.RemotePort + ProcessName = $conn.ProcessName + PID = $conn.PID + Detail = "AbuseIPDB score $score/100 for $ip - HIGH confidence malicious (ISP: $isp, Country: $country, Reports: $reports)" + }) + } + elseif ($score -ge $Config.AbuseIpDbWarnThreshold) { + $findings.Add([PSCustomObject]@{ + Type = 'AbuseIPDB_Warning' + Severity = 'Warning' + RemoteAddress = $ip + RemotePort = $conn.RemotePort + ProcessName = $conn.ProcessName + PID = $conn.PID + Detail = "AbuseIPDB score $score/100 for $ip - moderate abuse history (ISP: $isp, Country: $country, Reports: $reports)" + }) + } + } + } + return $findings +} + +function Find-SuspiciousProcessConnections { + param([object[]]$Connections) + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($conn in ($Connections | Where-Object { $_.IsExternal })) { + $procLower = $conn.ProcessName.ToLowerInvariant() + if ($ForbiddenExternalProcesses.Contains($procLower)) { + $findings.Add([PSCustomObject]@{ + Type = 'ForbiddenProcessExternal' + Severity = 'Critical' + RemoteAddress = $conn.RemoteAddress + RemotePort = $conn.RemotePort + ProcessName = $conn.ProcessName + PID = $conn.PID + Detail = "$($conn.ProcessName) (PID $($conn.PID)) has an external connection - this process should never communicate externally" + }) + } + elseif ($SuspiciousProcesses.Contains($procLower)) { + $findings.Add([PSCustomObject]@{ + Type = 'SuspiciousProcessExternal' + Severity = 'Critical' + RemoteAddress = $conn.RemoteAddress + RemotePort = $conn.RemotePort + ProcessName = $conn.ProcessName + PID = $conn.PID + Detail = "LOLBin/scripting engine $($conn.ProcessName) (PID $($conn.PID)) has external connection to $($conn.RemoteAddress):$($conn.RemotePort)" + }) + } + } + return $findings +} + +function Find-SuspiciousPortConnections { + param([object[]]$Connections) + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($conn in ($Connections | Where-Object { $_.IsExternal })) { + if ($SuspiciousPorts.Contains($conn.RemotePort)) { + $findings.Add([PSCustomObject]@{ + Type = 'SuspiciousPort' + Severity = 'Warning' + RemoteAddress = $conn.RemoteAddress + RemotePort = $conn.RemotePort + ProcessName = $conn.ProcessName + PID = $conn.PID + Detail = "$($conn.ProcessName) outbound to $($conn.RemoteAddress):$($conn.RemotePort) - port commonly associated with C2/RAT/reverse-shell activity" + }) + } + } + return $findings +} + +function Find-ExfiltrationIndicators { + param([object[]]$Connections) + $findings = [System.Collections.Generic.List[object]]::new() + foreach ($group in (@($Connections | Where-Object { $_.IsExternal }) | Group-Object { "$($_.ProcessName)|$($_.RemoteAddress)" })) { + if ($group.Count -ge $Config.ExfilMinConnections) { + $parts = $group.Name -split '\|' + $proc = $parts[0] + $dest = if ($parts.Count -gt 1) { $parts[1] } else { 'Unknown' } + $ports = ($group.Group | Select-Object -ExpandProperty RemotePort -Unique | Sort-Object) -join ', ' + $findings.Add([PSCustomObject]@{ + Type = 'ExfiltrationIndicator' + Severity = 'Warning' + RemoteAddress = $dest + RemotePort = $ports + ProcessName = $proc + PID = ($group.Group | Select-Object -First 1).PID + Detail = "$proc has $($group.Count) concurrent connections to $dest - possible beaconing or data exfiltration" + }) + } + } + return $findings +} + +function Find-DnsAnomalies { + param([object[]]$DnsEntries, [hashtable]$DnsHistory, [object]$ThreatIntel, [hashtable]$AbuseCache) + $findings = [System.Collections.Generic.List[object]]::new() + $now = Get-Date -Format s + + foreach ($entry in $DnsEntries) { + $domain = $entry.Name + + # Update DNS history + if (-not $DnsHistory.ContainsKey($domain)) { + $DnsHistory[$domain] = [PSCustomObject]@{ FirstSeen=$now; LastSeen=$now; SeenCount=1; Data=$entry.Data } + } + else { + try { $DnsHistory[$domain].LastSeen = $now } catch {} + try { $DnsHistory[$domain].SeenCount = [int]$DnsHistory[$domain].SeenCount + 1 } catch {} + } + + # Domain allowlist — skip known-good domains before any blocklist check + if ($DomainAllowlist.Contains($domain)) { continue } + + # URLhaus domain blocklist + if ($ThreatIntel.DomainSet.Contains($domain)) { + $findings.Add([PSCustomObject]@{ + Type = 'BlocklistedDomain' + Severity = 'Critical' + Domain = $domain + Data = $entry.Data + Detail = "DNS cache contains blocked domain '$domain' (URLhaus malware distribution list) resolving to $($entry.Data)" + }) + continue + } + + # Resolves to AbuseIPDB-flagged IP + if ($entry.Type -eq 'A' -and $AbuseCache.ContainsKey($entry.Data)) { + $score = -1 + try { $score = [int]$AbuseCache[$entry.Data].Score } catch {} + if ($score -ge $Config.AbuseIpDbCritThreshold) { + $findings.Add([PSCustomObject]@{ + Type = 'DomainResolvesToBlockedIP' + Severity = 'Critical' + Domain = $domain + Data = $entry.Data + Detail = "Domain '$domain' resolves to $($entry.Data) which has AbuseIPDB score $score/100" + }) + } + } + + # High-entropy label (DGA indicator) + $entropy = Get-DomainEntropy -Domain $domain + if ($entropy -ge $Config.HighEntropyThreshold) { + $findings.Add([PSCustomObject]@{ + Type = 'HighEntropyDomain' + Severity = 'Warning' + Domain = $domain + Data = $entry.Data + Detail = "High-entropy domain '$domain' (entropy=$entropy) in DNS cache - possible DGA/C2 communication" + }) + } + + # Suspicious TLD + if (Test-IsSuspiciousDomain -Domain $domain) { + $findings.Add([PSCustomObject]@{ + Type = 'SuspiciousTLD' + Severity = 'Warning' + Domain = $domain + Data = $entry.Data + Detail = "DNS cache contains domain '$domain' using a TLD with high abuse rates" + }) + } + } + return $findings +} + +function Update-ConnectionHistory { + param([object[]]$Connections, [hashtable]$History) + $new = [System.Collections.Generic.List[object]]::new() + $now = Get-Date -Format s + foreach ($conn in $Connections) { + $key = "$($conn.RemoteAddress):$($conn.RemotePort):$($conn.ProcessName.ToLowerInvariant())" + if (-not $History.ContainsKey($key)) { + $new.Add($conn) + $History[$key] = [PSCustomObject]@{ + RemoteAddress = $conn.RemoteAddress + RemotePort = $conn.RemotePort + ProcessName = $conn.ProcessName + IsExternal = $conn.IsExternal + FirstSeen = $now + LastSeen = $now + SeenCount = 1 + } + } + else { + try { $History[$key].LastSeen = $now } catch {} + try { $History[$key].SeenCount = [int]$History[$key].SeenCount + 1 } catch {} + } + } + return $new +} + +# ── Alert builders ──────────────────────────────────────────────────────────── +function Build-FindingAlertBody { + param([string]$Severity, [object[]]$Findings) + $lines = [System.Collections.Generic.List[string]]::new() + $newOnes = @($Findings | Where-Object { $_.IsNew }) + $ongoing = @($Findings | Where-Object { -not $_.IsNew }) + + $lines.Add("Network Traffic Investigation - $Severity Findings") + $lines.Add("Host : $env:COMPUTERNAME") + $lines.Add("Timestamp : $(Get-Date -Format s)") + $lines.Add("Findings : $(Get-SafeCount $Findings) ($(Get-SafeCount $newOnes) new, $(Get-SafeCount $ongoing) ongoing)") + $lines.Add('') + + $addFinding = { + param($f, $showFirstSeen) + $lines.Add("[$($f.Severity)] $($f.Type)") + if ($f.PSObject.Properties['ProcessName'] -and $f.ProcessName) { $lines.Add(" Process : $($f.ProcessName)") } + if ($f.PSObject.Properties['RemoteAddress'] -and $f.RemoteAddress) { $lines.Add(" Remote : $($f.RemoteAddress):$($f.RemotePort)") } + if ($f.PSObject.Properties['Domain'] -and $f.Domain) { $lines.Add(" Domain : $($f.Domain) -> $($f.Data)") } + if ($showFirstSeen -and $f.PSObject.Properties['FirstSeen'] -and $f.FirstSeen) { $lines.Add(" First Seen : $($f.FirstSeen)") } + $lines.Add(" Detail : $($f.Detail)") + $lines.Add('') + } + + if ((Get-SafeCount $newOnes) -gt 0) { + $lines.Add('--- NEW THIS RUN ---') + $lines.Add('') + foreach ($f in $newOnes) { & $addFinding $f $false } + } + + if ((Get-SafeCount $ongoing) -gt 0) { + $lines.Add('--- ONGOING (previously alerted, suppressed this run) ---') + $lines.Add('') + foreach ($f in $ongoing) { & $addFinding $f $true } + } + + $body = ($lines -join [Environment]::NewLine) + if ($body.Length -gt $Config.MaxAlertBodyLength) { + return $body.Substring(0, $Config.MaxAlertBodyLength) + "`r`n... truncated. Full report: $($Config.ReportPath)" + } + return $body +} + +function Build-SummaryReport { + param( + [object[]]$Connections, + [object[]]$AllFindings, + [object[]]$NewConnections, + [hashtable]$ConnectionHistory, + [hashtable]$DnsHistory, + [object]$ThreatIntel + ) + $lines = [System.Collections.Generic.List[string]]::new() + $lines.Add('Network Traffic Monitor - Full Run Report') + $lines.Add("Host : $env:COMPUTERNAME") + $lines.Add("Timestamp : $(Get-Date -Format s)") + $lines.Add("Active TCP Conns : $(Get-SafeCount $Connections)") + $lines.Add("New Connections : $(Get-SafeCount $NewConnections)") + $lines.Add("Historical Conns : $($ConnectionHistory.Count)") + $lines.Add("DNS History : $($DnsHistory.Count) domains") + $multiSource = ($ThreatIntel.IpScoreMap.Values | Where-Object { $_ -ge 2 } | Measure-Object).Count + $lines.Add("TI IPs Loaded : $($ThreatIntel.IpScoreMap.Count) ($multiSource multi-source)") + $lines.Add("TI CIDRs Loaded : $($ThreatIntel.CidrList.Count)") + $lines.Add("TI Domains Loaded : $($ThreatIntel.DomainSet.Count)") + $lines.Add("Total Findings : $(Get-SafeCount $AllFindings)") + $lines.Add('') + + if ((Get-SafeCount $AllFindings) -gt 0) { + $lines.Add('Findings by Severity') + foreach ($g in ($AllFindings | Group-Object Severity | Sort-Object Name)) { + $lines.Add(" $($g.Name): $(@($g.Group).Count)") + } + $lines.Add('') + $lines.Add('All Findings') + foreach ($f in $AllFindings) { + $lines.Add("[$($f.Severity)] $($f.Type): $($f.Detail)") + } + $lines.Add('') + } + + $lines.Add('Active External Connections') + foreach ($c in ($Connections | Where-Object { $_.IsExternal } | Sort-Object ProcessName)) { + $lines.Add(" $($c.ProcessName) (PID $($c.PID)) -> $($c.RemoteAddress):$($c.RemotePort) [$($c.State)]") + } + return ($lines -join [Environment]::NewLine) +} + +# ── Main execution ──────────────────────────────────────────────────────────── +$exitCode = 0 +try { + Write-Log 'Network Traffic Monitor starting...' + Ensure-Directory -Path $Config.DataDir + + $state = Load-State -Path $Config.StatePath + $connectionHistory = ConvertTo-Hashtable -PsObj $state.ConnectionHistory + $abuseIpDbCache = ConvertTo-Hashtable -PsObj $state.AbuseIpDbCache + $dnsHistory = ConvertTo-Hashtable -PsObj $state.DnsHistory + $findingHistory = ConvertTo-Hashtable -PsObj $state.FindingHistory + + $pruned = Prune-ConnectionHistory -History $connectionHistory -MaxDays $Config.ConnectionHistoryPruneDays + if ($pruned -gt 0) { Write-Log "Pruned $pruned stale connection history entries." } + + $threatIntel = Load-ThreatIntelLists + + Write-Log 'Building process map...' + $processMap = Get-ProcessMap + + Write-Log 'Collecting active TCP connections...' + $connections = @(Get-ActiveConnections -ProcessMap $processMap) + Write-Log "$($connections.Count) active connections found." + + Write-Log 'Collecting DNS cache...' + $dnsEntries = @(Get-DnsCacheEntries) + Write-Log "$($dnsEntries.Count) DNS cache entries found." + + $newConnections = @(Update-ConnectionHistory -Connections $connections -History $connectionHistory) + Write-Log "$($newConnections.Count) new connections this run." + + if (-not [string]::IsNullOrWhiteSpace($Config.AbuseIpDbApiKey)) { + $externalIps = @($connections | Where-Object { $_.IsExternal } | + Select-Object -ExpandProperty RemoteAddress -Unique) + $dnsIps = @($dnsEntries | Where-Object { $_.Type -eq 'A' } | + Where-Object { Test-IsRoutableAddress -Address $_.Data } | + Select-Object -ExpandProperty Data -Unique) + $allExternalIps = @(($externalIps + $dnsIps) | Select-Object -Unique) + Write-Log "AbuseIPDB enrichment for up to $($Config.AbuseIpDbMaxPerRun) new IPs (of $($allExternalIps.Count) unique)..." + Update-AbuseIpDbCache -IpAddresses $allExternalIps -Cache $abuseIpDbCache + } + else { + Write-Log 'AbuseIPDB API key not configured - skipping API enrichment.' + } + + Write-Log 'Running behavioral analysis...' + $allFindings = [System.Collections.Generic.List[object]]::new() + foreach ($f in @(Find-BlocklistHits -Connections $connections -ThreatIntel $threatIntel -AbuseCache $abuseIpDbCache)) { $allFindings.Add($f) } + foreach ($f in @(Find-SuspiciousProcessConnections -Connections $connections)) { $allFindings.Add($f) } + foreach ($f in @(Find-SuspiciousPortConnections -Connections $connections)) { $allFindings.Add($f) } + foreach ($f in @(Find-ExfiltrationIndicators -Connections $connections)) { $allFindings.Add($f) } + foreach ($f in @(Find-DnsAnomalies -DnsEntries $dnsEntries -DnsHistory $dnsHistory -ThreatIntel $threatIntel -AbuseCache $abuseIpDbCache)) { $allFindings.Add($f) } + Write-Log "Analysis complete - $($allFindings.Count) finding(s)." + + # Annotate findings with IsNew / FirstSeen and prune stale finding history + if ($allFindings.Count -gt 0) { + Update-FindingHistory -CurrentFindings $allFindings -History $findingHistory ` + -SuppressionHours $Config.FindingSuppressionHours -PruneDays $Config.FindingPruneDays + } + + $report = Build-SummaryReport -Connections $connections -AllFindings $allFindings ` + -NewConnections $newConnections -ConnectionHistory $connectionHistory ` + -DnsHistory $dnsHistory -ThreatIntel $threatIntel + try { + Ensure-ParentDirectory -Path $Config.ReportPath + Set-Content -LiteralPath $Config.ReportPath -Value $report -Encoding UTF8 -Force + } + catch { Write-Warning "Could not write report: $($_.Exception.Message)" } + + $criticalFindings = @($allFindings | Where-Object { $_.Severity -eq 'Critical' }) + $warningFindings = @($allFindings | Where-Object { $_.Severity -eq 'Warning' }) + $newCritical = @($criticalFindings | Where-Object { $_.IsNew }) + $newWarning = @($warningFindings | Where-Object { $_.IsNew }) + $suppressedCritical = @($criticalFindings | Where-Object { -not $_.IsNew }) + $suppressedWarning = @($warningFindings | Where-Object { -not $_.IsNew }) + + # Critical: alert only when new findings exist; close only when no findings remain + if ((Get-SafeCount $newCritical) -gt 0) { + $body = Build-FindingAlertBody -Severity 'Critical' -Findings $criticalFindings + if (Get-Command Rmm-Alert -ErrorAction SilentlyContinue) { Rmm-Alert -Category $Config.AlertCategoryCritical -Body $body } + else { Write-Output $body } + Write-Log "Critical alert raised: $(Get-SafeCount $newCritical) new, $(Get-SafeCount $suppressedCritical) ongoing." + } + elseif ((Get-SafeCount $criticalFindings) -gt 0) { + Write-Log "Critical: $(Get-SafeCount $criticalFindings) ongoing finding(s) active - suppressed (all previously alerted within $($Config.FindingSuppressionHours)h window)." + } + else { + if ($Config.CloseAlertWhenClean -and (Get-Command Close-Rmm-Alert -ErrorAction SilentlyContinue)) { + Close-Rmm-Alert -Category $Config.AlertCategoryCritical -CloseAlertTicket $false + } + } + + # Warning: same pattern + if ((Get-SafeCount $newWarning) -gt 0) { + $body = Build-FindingAlertBody -Severity 'Warning' -Findings $warningFindings + if (Get-Command Rmm-Alert -ErrorAction SilentlyContinue) { Rmm-Alert -Category $Config.AlertCategoryWarning -Body $body } + else { Write-Output $body } + Write-Log "Warning alert raised: $(Get-SafeCount $newWarning) new, $(Get-SafeCount $suppressedWarning) ongoing." + } + elseif ((Get-SafeCount $warningFindings) -gt 0) { + Write-Log "Warning: $(Get-SafeCount $warningFindings) ongoing finding(s) active - suppressed (all previously alerted within $($Config.FindingSuppressionHours)h window)." + } + else { + if ($Config.CloseAlertWhenClean -and (Get-Command Close-Rmm-Alert -ErrorAction SilentlyContinue)) { + Close-Rmm-Alert -Category $Config.AlertCategoryWarning -CloseAlertTicket $false + } + } + + if ((Get-SafeCount $allFindings) -eq 0) { + Write-Log 'No suspicious findings this run.' + } + + if (Get-Command Log-Activity -ErrorAction SilentlyContinue) { + Log-Activity -Message "Network traffic scan: $($connections.Count) connections, $($allFindings.Count) finding(s)." ` + -EventName $Config.LogEventName + } + + Save-State -Path $Config.StatePath -ConnectionHistory $connectionHistory ` + -AbuseIpDbCache $abuseIpDbCache -DnsHistory $dnsHistory -FindingHistory $findingHistory +} +catch { + $message = "Network traffic monitor failed: $($_.Exception.Message)" + [Console]::Error.WriteLine($message) + if (Get-Command Rmm-Alert -ErrorAction SilentlyContinue) { + try { Rmm-Alert -Category $Config.AlertCategoryError -Body $message } catch {} + } + $exitCode = 2 +} + +exit $exitCode