If you are a system administrator many times it’s quite hard to track group policy changes, the point of this is not to track exactly what’s changed within the group policy, but what changed in what group policy?
Eye 👁️ in the Sky
I have always wondered if it would be possible to track what group policy changed at what time and if preferable by who, after a quick scout around Google it would appear Microsoft have come over script to do exactly this, Where it takes a snapshot of the group policy objects, and then tracks changes and upon a change, it will tell you:
- The name of the group policy that changed
- The time the group policy changed
- The source domain controller it changed on
- The name of the user that changed the group policy
Access permissions, and prerequisites!
If you wish to query the security log of the domain controller, by default, you need to be an administrator, or in the context of this at the domain administrator, however, I would not recommend such a high-level of access, instead I would recommend you put the account you’re going to use to run this script in the “ Event log readers” group, you will also need to add to the group policy controls The following user rights assignment of “Manage and order security logs” to the service account as well.
Once these two permissions are granted, you can read the security event log using the event viewer management console, however, if you try to use Powershell, this will still not work as you’re not quite done yet…
This will still not give you access to the security log because you will get the error “This operation is not authorized”
The reason for this is very simple. If you take a look at the Registry key for the security event log you will notice only the following users are able to read this event log include System, Administrators and EventLog:
That means you need to add a custom registry entry Event Log Readers that gives it read access to the security log to be able to read the security log:
The exact permissions you require are as below:
Path : HKLM\System\CurrentControlSet\Services\Eventlog\Security
Permissions Assigned : Query Value, Enumerate Subkeys, Notify, Read Control
Runtimes : Subjective
This is also worth mentioning, when you run the script However, you choose to automate it, if no changes are detected it will run within 20 seconds.
If changes are detected, then needs to query every Single security log on your domain controller and search for the event ID 5136 and then extract the username from that event log entry, this can take some time depending on the size of your security log and how many domain controllers you have - personally, I allow up to 15 minutes for this script to run when it changes detected based on the size of our environment and event log size
Script Frequency
This is also another very subjective guideline. I personally run the script every hour on the hour indefinitely, however, that is down to your individual requirements - this script will generate the CSV file but will not obviously put them in your Inbox daily, if you would like to include this particular email notification that can be found towards the bottom of the article - I run this every day at 6 PM, the file you point it at can either be the changes for that day only, I’m alternatively, you can also point it at the file that will track every single group policy change since the script was installed.
The Script
All incredibly helpful stuff and the command is very quick to run when it doesn’t detect changes, when it does detect changes, please see the runtime above.
The script (in all its glory)
Import-Module GroupPolicy
Import-Module ActiveDirectory
$colNewGPOs = @()
$colAllNewGPOs = @()
[System.DirectoryServices.ActiveDirectory.Forest]::GetCurrentForest().Domains | Select Name | ForEach {
$DomainControllers = Get-ADDomainController -Server $_.Name -Filter *
[DateTime]$Date = "01/01/1981"
$count = $DomainControllers | measure-object
$i = 0
$Domain = $_.Name
$FileName = $Domain + "_GPOs.csv"
$FilePath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
If ((Test-Path ($FilePath + "\All_GPOs")) -eq $false){New-Item -ItemType Directory -Force -Path ($FilePath + "\All_GPOs")}
If ((Test-Path ($FilePath + "\Modified_GPOs")) -eq $false){New-Item -ItemType Directory -Force -Path ($FilePath + "\Modified_GPOs")}
$FilePath = $FilePath + "\All_GPOs\" + $FileName
If (Test-Path $FilePath){$arrayOld = @(Import-CSV $FilePath)} Else {$arrayOld = @()}
$arrayNew = @(Get-GPO -all -Domain $Domain | Select DisplayName, ModificationTime, Id | Sort-Object DisplayName)
$arrayNew | Export-CSV $FilePath -notype
ForEach($oldItem in $ArrayOld){
$i = 0
ForEach($newItem in $arrayNew){
$LoginID = "n/a"
$SourceDC = "n/a"
If($oldItem.Id -ceq $newItem.Id){
$oldDate = Get-Date($oldItem.ModificationTime)
$newDate = Get-Date($newItem.ModificationTime)
If($oldDate -ne $newDate){
$LoginID = "n/a"
$SourceDC = "n/a"
$i = 0
$objNewGPOs = New-Object System.Object
$objNewGPOs | Add-Member -type NoteProperty -name Name -value $newItem.DisplayName
$objNewGPOs | Add-Member -type NoteProperty -name LastModified -value $newItem.ModificationTime
$start = $newItem.ModificationTime
$end = $start.addMinutes(10)
$start = $start.addMinutes(-10)
$start = Get-Date $start -format G
$end = Get-Date $end -format G
Foreach($DC in $DomainControllers)
{
$DefaultPartition = $DC.DefaultPartition
$i++
Write-Progress -Activity "Querying Domain Controllers" -status $DC.HostName -percentComplete ($i / $count.Count*100)
$GUID = [String]$newItem.Id
$GUID = "{" + $GUID + "}"
$DCHostname = $DC.HostName
write-host "$DefaultPartition : $GUID : $DCHostname"
Try{
$content = Get-WinEvent -MaxEvents 1 -ComputerName $DC.HostName -FilterXML "<QueryList><Query Id=`"0`" Path=`"Security`"><Select Path=`"Security`">*[EventData[Data and (Data=`"CN=$GUID,CN=POLICIES,CN=SYSTEM,$DefaultPartition`")]] and *[System[(EventID=5136)]]</Select></Query></QueryList>" -ErrorAction Stop
$content
$eventXML = [XML]$content.ToXML()
For ($i=0; $i -lt $eventXML.Event.EventData.Data.Count; $i++){If (($eventXML.Event.EventData.Data[$i].'Name') -eq "SubjectUserName"){$LoginID = $eventXML.Event.EventData.Data[$i].'#Text'}}
$SourceDC = $DC.HostName
$SourceDC
$LoginID
}
Catch{
$($_.Exception.Message)
}
}
$objNewGPOs | Add-Member -type NoteProperty -name ModifiedBy -value $LoginID
$objNewGPOs | Add-Member -type NoteProperty -name SourceDC -value $SourceDC
$colNewGPOs += $objNewGPOs
}
}
}
}
$FileName = "All_Current_GPO_Modifications.csv"
$FilePath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
$FilePath = $FilePath + "\Modified_GPOs\" + $FileName
$colAllNewGPOs += $colNewGPOs
$colAllNewGPOs | Sort-Object LastModified | Export-CSV $FilePath -notype
$FileName = $Domain + "_GPO_Modifications.csv"
$FilePath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
$FilePath = $FilePath + "\Modified_GPOs\" + $FileName
If (Test-Path $FilePath){
$arrayOldFile = @(Import-CSV $FilePath) | ForEach{
$objNewGPOs = New-Object System.Object
$objNewGPOs | Add-Member -type NoteProperty -name Name -value $_.Name
$objNewGPOs | Add-Member -type NoteProperty -name LastModified -value $_.LastModified
$objNewGPOs | Add-Member -type NoteProperty -name ModifiedBy -value $_.ModifiedBy
$objNewGPOs | Add-Member -type NoteProperty -name SourceDC -value $_.SourceDC
$colNewGPOs += $objNewGPOs
}
} Else {$arrayOldFile = @()}
$colNewGPOs | Sort-Object LastModified | Export-CSV $FilePath -notype
$colNewGPOs = @()
}
I chose to place the script in a folder called "GPO-Manager" as you can see below this shows the script and the resulting folders:
The "All_GPOs" folder contains a list of all the group policies in a CSV based format like this, I have only chosen a couple "well known" ones to show you here, this is where the baseline data is stored:
"Default Domain Controllers Policy","13/03/2021 00:02:00","6ac1786c-016f-11d2-945f-00c04fb984f9"
"Default Domain Policy","18/05/2022 10:56:30","31b2f340-016d-11d2-945f-00c04fb984f9"
Then the folder called "Modified_GPOs" will contain two files one called "all_current_gpo_modifications" which will contain a list of all the group policy objects modified at all since the baseline, and the second file is "bear.local_gpo_modifictions" which will contain more details about the modifcation:
If you open the first file it will show you all GPOs modified overall:
Excellent now you need to get a Scheduled Task setup to run this script daily at a certain time to ensure you data is updated and synchronised, I have already got the XML for the scheduled task if you want that data for easy setup, see below.
"Name","LastModified","ModifiedBy","SourceDC"
"Default Domain Policy","13/04/2024 19:26:38","naughty.bear","cubscout1"
If you open up the second file it will show all everytime a group policy has been modified:
"Name","LastModified","ModifiedBy","SourceDC"
"Default Domain Policy","13/04/2024 19:26:38","naughty.bear","xerxes.ai"
"Default Domain Policy","13/04/2024 19:26:38","bad.admin1","roadrunner"
Scheduled Task XML
All you need to to complete the XML is get the SID for the user that has LogonAsBatchJob rights and valid access to the Domain Controllers Security log, once you have an account you can use the command to look up the SID:
Get-AdUser -Identity <loginid> | Select Name, SID, UserPrincipalName
Get-AdUser -Identity <loginid> | Select Name, SID, UserPrincipalName
Once you have the SID that will look like this:
S-1-5-21-123697389-1196883430-1112475604-1016
You can move on to the XML file contents below.
<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<Date>2022-11-24T17:06:21.6592172</Date>
<Author>SYSTEM</Author>
<URI>\Group Policy Monitor</URI>
</RegistrationInfo>
<Triggers>
<CalendarTrigger>
<StartBoundary>2024-04-11T17:00:00</StartBoundary>
<Enabled>true</Enabled>
<ScheduleByDay>
<DaysInterval>1</DaysInterval>
</ScheduleByDay>
</CalendarTrigger>
</Triggers>
<Principals>
<Principal id="Author">
<UserId>SID removed for security</UserId>
<LogonType>Password</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>true</StopIfGoingOnBatteries>
<AllowHardTerminate>true</AllowHardTerminate>
<StartWhenAvailable>false</StartWhenAvailable>
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
<IdleSettings>
<StopOnIdleEnd>true</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
<AllowStartOnDemand>true</AllowStartOnDemand>
<Enabled>true</Enabled>
<Hidden>false</Hidden>
<RunOnlyIfIdle>false</RunOnlyIfIdle>
<WakeToRun>false</WakeToRun>
<ExecutionTimeLimit>PT1H</ExecutionTimeLimit>
<Priority>7</Priority>
</Settings>
<Actions Context="Author">
<Exec>
<Command>powershell.exe</Command>
<Arguments>-file "C:\GPO-Manager\GPO-ChangesTracker.ps1"</Arguments>
<WorkingDirectory>C:\GPO-Manager</WorkingDirectory>
</Exec>
</Actions>
</Task>
📧 Receive email of CSV file
📧 Receive email of CSV file
If you were looking to get an email daily about anything that changed then you can use this particular script to send that email, I would rather split them out because I want the monitor script to run hourly but don’t want to get a email every hour giving me updates, I’d rather have that excitement at 6 PM every evening.
Xxx