EventMonitor/Start-EventMonitorService.ps1
|
#Requires -Version 7.4 #Requires -RunAsAdministrator <# .SYNOPSIS Event-driven entry point for EventMonitor.Windows. .DESCRIPTION Starts real-time event monitoring using EventLogWatcher (zero-polling) plus a watchdog timer for health checks and catch-up sweeps. This script runs continuously as a long-running process. Use it with: - A Windows Service (via NSSM or sc.exe) - A Scheduled Task with "Do not start a new instance" + "Run whether user is logged on or not" - Direct invocation for testing (Ctrl+C to stop) Architecture: 1. Registers EventLogWatchers for each event category (instant, event-driven) 2. Starts a watchdog timer (every 30 min by default) that: - Checks watcher health and auto-repairs dead watchers - Runs a lightweight catch-up sweep for critical events - Flushes telemetry and reports health metrics 3. On shutdown (Ctrl+C or service stop): gracefully disposes all watchers Safety guarantees: - Every callback is independently try/caught — one bad event never crashes anything - Watchdog auto-restarts failed watchers - Catch-up sweep ensures zero event loss even during watcher restarts - Graceful shutdown with Flush on Ctrl+C .PARAMETER sessionId Correlation identifier for this monitoring session. .PARAMETER watchdogIntervalMin How often the watchdog runs health checks and catch-up sweeps. Default: 30 minutes. .EXAMPLE .\Start-EventMonitorService.ps1 -sessionId (New-Guid).Guid .EXAMPLE # As a Windows Service via NSSM: nssm install WindowsEventMonitor "C:\Program Files\PowerShell\7\pwsh.exe" nssm set WindowsEventMonitor AppParameters "-NoProfile -File C:\...\Start-EventMonitorService.ps1 -sessionId auto" nssm set WindowsEventMonitor ObjectName "LocalSystem" nssm start WindowsEventMonitor #> param( [string]$sessionId = [guid]::NewGuid().Guid, [ValidateRange(5, 1440)] [int]$watchdogIntervalMin = 30 ) # ── Bootstrap ───────────────────────────────────────────────────────────────── $modulePath = Join-Path $PSScriptRoot 'WindowsEventMonitor.psm1' Import-Module $modulePath -Force -ErrorAction Stop # OS check $osVersion = [System.Environment]::OSVersion.Version if ($osVersion.Major -lt 10) { Write-EMLog -Message "Unsupported OS: $osVersion. Requires Windows 10/Server 2016+." -Level Error exit 1 } # ── Register Telemetry Sinks ────────────────────────────────────────────────── # App Insights sink (env var > file > skip) $connectionString = Resolve-AppInsightsConnectionString if ($connectionString) { Register-AppInsightsSink -ConnectionString $connectionString Write-EMLog -Message 'AppInsights telemetry sink registered.' -Level Warning } else { Write-EMLog -Message 'No App Insights connection string found. Register custom sinks or set APPLICATIONINSIGHTS_CONNECTION_STRING / EventMonitorAppInsightsConString env var.' -Level Warning } # Additional sinks can be registered here: # Register-TelemetrySink -Name 'Webhook' -OnDispatch { param($Type, $Name, $Props) ... } Write-EMLog -Message "=== Event Monitor Service starting (session: $sessionId, mode: event-driven) ===" # ── Event Callback Factories ───────────────────────────────────────────────── # Each callback receives an EventRecord and dispatches it to the correct processor. # These are intentionally simple — the heavy logic lives in the processor functions. $securityCallback = { param($EventRecord, $SessionId) $props = New-EventProperties -SessionId $SessionId -EventType 'Info' -Severity 'Info' # Merge all event metadata via Send-LogAnalyticsConnectEvents $eventId = $EventRecord.Id $eventName = switch ($eventId) { 4624 { $props['EventType'] = 'Connect'; $props['Severity'] = 'Info'; '4624 Logon Success' } 4625 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4625 Logon Failed' } 4647 { $props['EventType'] = 'Disconnect'; $props['Severity'] = 'Info'; '4647 Logoff' } 4648 { $props['EventType'] = 'Connect'; $props['Severity'] = 'Medium'; '4648 Explicit Credential' } 4672 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4672 Special Privileges' } 4688 { $props['EventType'] = 'Info'; $props['Severity'] = 'Medium'; '4688 Process Created' } 4689 { $props['EventType'] = 'Info'; $props['Severity'] = 'Low'; '4689 Process Terminated' } 4697 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Critical'; '4697 Service Installed' } 4698 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Critical'; '4698 Task Created' } 4702 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4702 Task Updated' } 4719 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Critical'; '4719 Audit Policy Changed' } 4720 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Critical'; '4720 Account Created' } 4722 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4722 Account Enabled' } 4723 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Medium'; '4723 Password Change' } 4724 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4724 Password Reset' } 4725 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4725 Account Disabled' } 4726 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Critical'; '4726 Account Deleted' } 4732 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Critical'; '4732 Group Member Added' } 4733 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4733 Group Member Removed' } 4779 { $props['EventType'] = 'Disconnect'; $props['Severity'] = 'Info'; '4779 Session Disconnect' } 4800 { $props['EventType'] = 'Info'; $props['Severity'] = 'Info'; '4800 Workstation Locked' } 4801 { $props['EventType'] = 'Connect'; $props['Severity'] = 'Info'; '4801 Workstation Unlocked' } 1102 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Critical'; '1102 Audit Log Cleared' } 4946 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4946 Firewall Rule Added' } 4947 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '4947 Firewall Rule Modified' } 4948 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Critical'; '4948 Firewall Rule Deleted' } 5140 { $props['EventType'] = 'Connect'; $props['Severity'] = 'Medium'; '5140 Share Accessed' } 5152 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Medium'; '5152 Packet Dropped' } 5157 { $props['EventType'] = 'Alert'; $props['Severity'] = 'Medium'; '5157 Connection Blocked' } default { "Security Event $eventId" } } Send-LogAnalyticsConnectEvents ` -eventName $eventName -Properties $props -sendEvent $EventRecord } $systemCallback = { param($EventRecord, $SessionId) $props = New-EventProperties -SessionId $SessionId -EventType 'Info' -Severity 'Info' $eventName = switch ($EventRecord.Id) { 41 { $props['Severity'] = 'Critical'; '41 Unexpected Shutdown' } 1074 { '1074 Planned Shutdown' } 1076 { $props['Severity'] = 'High'; '1076 Unexpected Shutdown Reason' } 6005 { '6005 EventLog Service Started' } 6006 { '6006 EventLog Service Stopped' } 6008 { $props['Severity'] = 'High'; '6008 Unexpected Shutdown' } 6009 { '6009 OS Version at Boot' } 6013 { '6013 System Uptime' } 7045 { $props['EventType'] = 'Alert'; $props['Severity'] = 'High'; '7045 Service Installed' } default { "System Event $($EventRecord.Id)" } } Send-LogAnalyticsConnectEvents ` -eventName $eventName -Properties $props -sendEvent $EventRecord } $rdpCallback = { param($EventRecord, $SessionId) $eventType = if ($EventRecord.Id -in 21, 25) { 'Connect' } else { 'Disconnect' } $props = New-EventProperties -SessionId $SessionId -EventType $eventType -Severity 'Info' $description = switch ($EventRecord.Id) { 21 { 'RDP Session Logon' } 23 { 'RDP Session Logoff' } 24 { 'RDP Session Disconnected' } 25 { 'RDP Session Reconnected' } } $props['UserName'] = "$($EventRecord.Properties[0].Value)" $props['RDPSessionId'] = "$($EventRecord.Properties[1].Value)" $props['SourceIP'] = "$($EventRecord.Properties[2].Value)" Send-LogAnalyticsConnectEvents ` -eventName "$($EventRecord.Id) $description" -Properties $props -sendEvent $EventRecord } $powershellCallback = { param($EventRecord, $SessionId) $props = New-EventProperties -SessionId $SessionId -EventType 'Alert' -Severity 'Medium' $props['ScriptBlockId'] = "$($EventRecord.Properties[3].Value)" $props['ScriptPath'] = "$($EventRecord.Properties[4].Value)" $scriptText = "$($EventRecord.Properties[2].Value)" if ($scriptText.Length -gt 4000) { $scriptText = $scriptText.Substring(0, 4000) + '...[truncated]' } $props['ScriptBlockText'] = $scriptText Send-LogAnalyticsConnectEvents ` -eventName '4104 PowerShell Script Block' -Properties $props -sendEvent $EventRecord } $sshCallback = { param($EventRecord, $SessionId) $propValue = "$($EventRecord.Properties[1].Value)" if ($propValue -like 'Accepted publickey*') { $props = New-EventProperties -SessionId $SessionId -EventType 'Connect' -Severity 'Info' $eventName = 'SSH Connect' } elseif ($propValue -like 'Disconnected*') { $props = New-EventProperties -SessionId $SessionId -EventType 'Disconnect' -Severity 'Info' $eventName = 'SSH Disconnect' } else { return } $props['UserSID'] = "$($EventRecord.UserId)" Send-LogAnalyticsConnectEvents ` -eventName $eventName -Properties $props -sendEvent $EventRecord } # ── Register All Watchers ───────────────────────────────────────────────────── Write-EMLog -Message 'Registering event watchers...' $commonParams = @{ SessionId = $sessionId } # Security log — all monitored event IDs $securityIds = @( 1102, 4624, 4625, 4647, 4648, 4672, 4688, 4689, 4697, 4698, 4702, 4719, 4720, 4722, 4723, 4724, 4725, 4726, 4732, 4733, 4779, 4800, 4801, 4946, 4947, 4948, 5140, 5152, 5157 ) Register-EventWatcher -WatcherName 'Security' -LogName 'Security' ` -EventIds $securityIds -OnEvent $securityCallback @commonParams # System log $systemIds = @(41, 1074, 1076, 6005, 6006, 6008, 6009, 6013, 7045) Register-EventWatcher -WatcherName 'System' -LogName 'System' ` -EventIds $systemIds -OnEvent $systemCallback @commonParams # RDP (TerminalServices) — may not exist on non-RDP machines Register-EventWatcher -WatcherName 'RDP' ` -LogName 'Microsoft-Windows-TerminalServices-LocalSessionManager/Operational' ` -EventIds @(21, 23, 24, 25) -OnEvent $rdpCallback @commonParams # PowerShell Script Block Logging — may not exist if policy not enabled Register-EventWatcher -WatcherName 'PowerShell' ` -LogName 'Microsoft-Windows-PowerShell/Operational' ` -EventIds @(4104) -OnEvent $powershellCallback @commonParams # OpenSSH — may not exist if OpenSSH Server not installed Register-EventWatcher -WatcherName 'SSH' ` -LogName 'OpenSSH/Operational' ` -OnEvent $sshCallback @commonParams # ── Enable All Watchers ────────────────────────────────────────────────────── foreach ($name in @($script:EventWatchers.Keys)) { Enable-EventWatcher -WatcherName $name } Write-EMLog -Message "All watchers enabled ($($script:EventWatchers.Count) total)." # ── Start Watchdog ──────────────────────────────────────────────────────────── Start-Watchdog -IntervalMinutes $watchdogIntervalMin -SessionId $sessionId # ── Run Until Stopped ───────────────────────────────────────────────────────── # The watchers and watchdog run on background threads. This main thread just # waits for Ctrl+C or service stop signal. Write-EMLog -Message "Event Monitor Service running. Watchers: $($script:EventWatchers.Count), Watchdog: every ${watchdogIntervalMin}min." Write-EMLog -Message 'Press Ctrl+C to stop (or stop the Windows Service).' try { # Register Ctrl+C handler for graceful shutdown $null = [Console]::TreatControlCAsInput = $false # Keep alive — sleep in 10-second intervals so Ctrl+C is responsive while ($true) { Start-Sleep -Seconds 10 } } finally { # ── Graceful Shutdown ───────────────────────────────────────────────── Write-EMLog -Message '=== Shutting down Event Monitor Service ===' Stop-Watchdog Stop-AllEventWatchers # Final flush try { Flush-Telemetry } catch { $null = $null } Write-EMLog -Message '=== Event Monitor Service stopped ===' } |