Cybertek-Detection-Scripts/event_log_monitoring/event_log_monitor.ps1

598 lines
24 KiB
PowerShell

#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