I've covered Group Policy monitoring, scripting, and alerting in previous posts, but those solutions typically rely on sizable event logs with extended retention periods. In my current environment, I maintain a 13GB security log on domain controllers - which isn't particularly large and could be considered unlucky for some (though I'm not superstitious!).
However, what if you're working with limited space on your domain controllers? Perhaps your organization decided that a 100GB C: drive is "plenty of space" for a domain controller. Because clearly, whoever made that decision has never seen a Windows update, a security log, or apparently owns a smartphone with more storage than their critical infrastructure.
Or worse - someone decided that domain controllers make excellent file servers. I've walked into environments where the IT team stored PST files, SharePoint backups, and mysterious "emergency software" collections on the domain controller's C: drive. If you're doing this, please stop.
WARNING: If you have SYSVOL, the Active Directory database, and log files on the same drive as your operating system, running out of space could not only corrupt Windows but also the Active Directory database itself. With Windows updates becoming larger and system log files growing continuously, anything below 100GB for a system drive is not acceptable - especially on a domain controller. Seriously, your iPhone probably has more storage.
I disagrees, maybe you have Microsoft Sentinel managing your security logs, and someone decided you need to reduce local retention to 4GB - this way, the lock data will not be lost, but just recorded in Sentinel.
The Problem with Reduced Log Retention
Here's the reality: if you configure all the recommended security audit settings on a domain controller and reduce your security log size to 4GB, you might only get a couple of hours of logging at best. This severely reduces the time window available for PowerShell scripts to detect Group Policy changes.
Traditional GPO monitoring scripts that run once daily at midnight might miss changes entirely if they occurred 25 hours ago but the logs only retain 3 hours of data.
The Solution: Multiple Daily Snapshots
This is where my new script approach comes in. Instead of relying on a single daily scan with a large time window, the script takes three snapshots of Group Policy changes throughout the day:
- 11:00 AM - Morning scan (clears previous day's data)
- 4:00 PM - Afternoon scan (appends to daily log)
- 10:00 PM - Evening scan (appends to daily log)
Each snapshot appends changes to the same file throughout the day, and on the morning of the next day, the file is cleared. This approach preserves visibility for teams that don't have access to Sentinel or environments where domain controllers have limited system drives.
Script Architecture
The script creates two separate log files:
GPOChanges.csv (Email-Ready Data)
Contains only actual Group Policy modifications - clean data perfect for email alerts:
# Only log actual GPO changes for emailing
if ($allEvents.Count -gt 0) {
$allEvents | Select-Object TimeCreated, DC, SubjectUserName, GUID, DisplayName, VersionNumber |
Export-Csv -Path "GPOChanges.csv" -NoTypeInformation -Append
}
GPORunLog.csv (Operational Tracking)
Tracks script execution for troubleshooting and operational monitoring:
# Log operational information
$runType = if ($isFirstRunOfDay) { "11am run" } else { "4pm/10pm run" }
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),$runType,Completed,$($allEvents.Count) events found" |
Out-File -FilePath "GPORunLog.csv" -Append -Encoding UTF8
Daily Log Management Logic
The key innovation is the intelligent daily log management:
# Determine if this is the first run of the day (11am = clear log, others = append)
$currentHour = (Get-Date).Hour
$isFirstRunOfDay = ($currentHour -eq 11)
# Clear GPO log file only on first run of the day (11am)
if ($isFirstRunOfDay) {
Clear-Content GPOChanges.csv -Force
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),11am run,Started,Cleared GPO log" |
Out-File -FilePath "GPORunLog.csv" -Encoding UTF8
} else {
# Subsequent runs append to existing log
$runType = if ($currentHour -eq 16) { "4pm run" } elseif ($currentHour -eq 22) { "10pm run" } else { "Manual run" }
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),$runType,Started,Appending to GPO log" |
Out-File -FilePath "GPORunLog.csv" -Append -Encoding UTF8
}
Event Query with Reduced Time Window
Since we're running three times daily, we can use a shorter 24-hour lookback window instead of 48-72 hours:
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[(EventID=5136) and TimeCreated[timediff(@SystemTime) <= 86400000]]]
and
*[EventData[Data[@Name='ObjectClass'] and (Data='groupPolicyContainer')]]
and
*[EventData[Data[@Name='AttributeLDAPDisplayName'] and (Data='versionNumber')]]
and
*[EventData[Data[@Name='OperationType'] and (Data='%%14674')]]
</Select>
</Query>
</QueryList>
The 86400000 milliseconds equals exactly 24 hours, providing overlap between runs while working within constrained log retention.
Deduplication Process
Since multiple scans may detect the same change, the script includes automatic deduplication:
# Remove duplicates while preserving unique changes
$uniqueData = $processedData | Sort-Object -Property * -Unique
$duplicatesRemoved = $initialRows - $finalCount
Write-Host "✓ Removed $duplicatesRemoved duplicate entries" -ForegroundColor Green
Scheduled Task Configuration
Set up three scheduled tasks with these triggers:
- Daily at 11:00 AM (clears previous day)
- Daily at 4:00 PM (appends)
- Daily at 10:00 PM (appends)
Each task runs the same script file - the script automatically determines whether to clear or append based on the execution time.
The Complete Script
# Import required modules
Import-Module ActiveDirectory
Import-Module GroupPolicy
# Set working directory for scheduled task compatibility
Set-Location "C:\Quarantine\gpo-checkandemail-intervals\Production"
# Determine if this is the first run of the day (11am = clear log, others = append)
$currentHour = (Get-Date).Hour
$isFirstRunOfDay = ($currentHour -eq 11)
# Clear GPO log file only on first run of the day (11am)
if ($isFirstRunOfDay) {
Clear-Content GPOChanges.csv -Force
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),11am run,Started,Cleared GPO log" | Out-File -FilePath "GPORunLog.csv" -Encoding UTF8
} else {
$runType = if ($currentHour -eq 16) { "4pm run" } elseif ($currentHour -eq 22) { "10pm run" } else { "Manual run" }
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),$runType,Started,Appending to GPO log" | Out-File -FilePath "GPORunLog.csv" -Append -Encoding UTF8
}
# XML query with time limitation
$xmlQuery = @"
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[(EventID=5136) and TimeCreated[timediff(@SystemTime) <= 86400000]]]
and
*[EventData[Data[@Name='ObjectClass'] and (Data='groupPolicyContainer')]]
and
*[EventData[Data[@Name='AttributeLDAPDisplayName'] and (Data='versionNumber')]]
and
*[EventData[Data[@Name='OperationType'] and (Data='%%14674')]]
</Select>
</Query>
</QueryList>
"@
# Function to resolve GUID to GPO display name
function Get-GPODisplayName {
param($guidString)
if ([string]::IsNullOrWhiteSpace($guidString)) {
Write-Warning "Empty GUID string provided"
return "Unknown GPO (Empty GUID)"
}
try {
$gpo = Get-GPO -Guid $guidString -ErrorAction Stop
return $gpo.DisplayName
} catch {
$errorMessage = $_.Exception.Message
Write-Warning "Error retrieving GPO for GUID $guidString : $errorMessage"
return $null
}
}
# Get all domain controllers
$domainControllers = Get-ADDomainController -Filter * | Select-Object -ExpandProperty Hostname
# Array to store all events
$allEvents = @()
# Query each domain controller
foreach ($dc in $domainControllers) {
Write-Host "Querying $dc..."
$events = Get-WinEvent -FilterXml $xmlQuery -ComputerName $dc -ErrorAction SilentlyContinue
if ($events) {
$allEvents += $events | ForEach-Object {
$eventXML = [xml]$_.ToXml()
$objectDN = $eventXML.Event.EventData.Data | Where-Object { $_.Name -eq 'ObjectDN' } | Select-Object -ExpandProperty '#text'
$guidString = $objectDN -replace 'CN=\{(.*?)\},.*', '$1'
$subjectUserName = $eventXML.Event.EventData.Data | Where-Object { $_.Name -eq 'SubjectUserName' } | Select-Object -ExpandProperty '#text'
$subjectDomainName = $eventXML.Event.EventData.Data | Where-Object { $_.Name -eq 'SubjectDomainName' } | Select-Object -ExpandProperty '#text'
$versionNumber = $eventXML.Event.EventData.Data | Where-Object { $_.Name -eq 'AttributeValue' } | Select-Object -ExpandProperty '#text'
$eventData = @{
TimeCreated = $_.TimeCreated
DC = $dc
SubjectUserName = "$subjectDomainName\$subjectUserName"
GUID = $guidString
DisplayName = (Get-GPODisplayName $guidString)
VersionNumber = $versionNumber
}
New-Object PSObject -Property $eventData
}
}
}
# Export to CSV
if ($allEvents.Count -gt 0) {
$allEvents | Select-Object TimeCreated, DC, SubjectUserName, GUID, DisplayName, VersionNumber |
Export-Csv -Path "GPOChanges.csv" -NoTypeInformation -Append
}
# Log completion
$runType = if ($isFirstRunOfDay) { "11am run" } elseif ($currentHour -eq 16) { "4pm run" } elseif ($currentHour -eq 22) { "10pm run" } else { "Manual run" }
"$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss'),$runType,Completed,$($allEvents.Count) events found" | Out-File -FilePath "GPORunLog.csv" -Append -Encoding UTF8
Write-Host "Report has been exported to GPOChanges.csv"
# GPO Changes Processing Script
$ErrorActionPreference = 'Stop'
function Process-GPOChanges {
try {
$csv = Import-Csv -Path "GPOChanges.csv"
$initialRows = $csv.Count
# Remove TimeCreated and VersionNumber columns
$processedData = $csv | Select-Object -Property * -ExcludeProperty TimeCreated, VersionNumber
# Remove duplicates
$uniqueData = $processedData | Sort-Object -Property * -Unique
$finalCount = $uniqueData.Count
$duplicatesRemoved = $initialRows - $finalCount
# Save the processed data
$uniqueData | Export-Csv -Path "GPOChanges.csv" -NoTypeInformation
Write-Host "Processing completed: $duplicatesRemoved duplicates removed, $finalCount unique changes retained"
}
catch [System.IO.FileNotFoundException] {
Write-Host "No GPO changes file found - no processing needed"
}
catch {
Write-Host "Error processing GPO changes: $($_.Exception.Message)"
}
}
# Run the processing function
Process-GPOChanges
Conclusion
This multi-snapshot approach solves the fundamental problem of Group Policy monitoring in constrained environments. Whether you're dealing with legacy infrastructure, limited storage, or aggressive log rotation policies, this script ensures you maintain visibility into critical GPO changes without requiring massive event log retention.