#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