Group policy, all the way of getting policies in a domain to computers via configuration settings however you also have the linkage of those policies, if you create a group policy object, and do not link it to organization unit (OU) this policy will not apply to any computers until it is linked to an OU.
If you create a new policy and link it to an OU that is called a GPLink and the operation there is “add” therefore by the same method, if you remove a link to a group policy object that is called a “delete” finally you have another type of link called "enforce", this will ignore the usual group policy precedence and if you meet the criteria for that group policy, it will apply and override other group policies.
When you have multiple group policies setting the same thing you need to remember that the loopback processing mode comes into effect and the options you have there or merge or replace
If you have a group policy object that sets different colored wallpaper, for example, The order of the processing order is LSDOU (I always remember this as LSD Ohhh) - this means the order will be local, site, domain, or organizational unit.
This means if you have a red wallpaper locally on the computer, you then have a domain wallpaper that says blue, and finally, you have an OU wallpaper that says purple - purple should be the group policy that wins and you should then observe a purple wallpaper if all the Group policies apply correctly, Obviously, the only exception of this is enforce and if that group policy states a pink wallpaper then, if you meet the enforcement criteria, you will get a pink wallpaper, regardless of the other group policies.
Group policy objects should also be granular in what they control, If you have one large group policy to control certain actions, it can be very tricky to diagnose what’s causing the problem, if you are having to go through over 600 settings to figure out where the problem could be, If you have more fine grain policies and more being applied ironically that actually took less time then apply a gigantic policy to a server with the option of settings being missed off (less is definitely better than more in the scenario)
It could be argued that tracking linkage ads and removals can be more damaging to your organizational environment than the actual group policy objects themselves that without being linked don’t actually do anything.
This is where this particular script comes in, obviously, it’s quite normal for an organization to have one group policy applied to many organizational units, which is actually quite hard to track with a script that runs occasionally, so we need to apply some logic to this.
The script first scans your domain for all the group policy objects, and their existing links whether this is created and disabled, created and enabled or enforced, this will get saved to a baseline file, then, when the script runs again, it compares what change between the baseline and what the current configuration is and gives you a human readable report.
This is done in a couple of steps, let go though them....
First we need to track GPO updates which can be done by looking at the structure of Group Policy Management and taking that baseline, so when you run the script the first step is to get the baseline as you can see below:
[2024-11-27 14:00:25] Comparing against existing baseline...
[2024-11-27 14:00:25] New link detected for GPO 'Test Link Policy'
[2024-11-27 14:00:25] - Linked to: bear.local/Servers/Script Testing/
[2024-11-27 14:00:25] Baseline updated.
Script : GPOScan.ps1
Note : This script creates a "snapshot" of the state of the gpLink assignments so it can only see what has been done between these snapshots, so you will need to run it frequently to get a more accurate picture of the changes, however if you have a run where a policy is link, then unlinked a couple of times it will only capture the "end" state
# Script to monitor GPO link changes
Import-Module GroupPolicy
Import-Module ActiveDirectory
# Define paths
$baselinePath = "GPOLinksBaseline.json"
$logFile = "GPOLinkChanges.log"
function Write-Log {
param($Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logEntry = "[$timestamp] $Message"
Write-Host $logEntry
Add-Content -Path $logFile -Value $logEntry
}
function Get-CurrentGPOLinks {
$gpoLinks = @{}
# Get all GPOs
$gpos = Get-GPO -All
foreach ($gpo in $gpos) {
# Get all links for this GPO using the XML report
$report = [xml](Get-GPOReport -Name $gpo.DisplayName -ReportType XML)
$links = $report.GPO.LinksTo | ForEach-Object { $_.SOMPath }
if ($links) {
$gpoLinks[$gpo.DisplayName] = $links
}
}
return $gpoLinks
}
function Compare-GPOLinks {
param($Baseline, $Current)
foreach ($gpoName in $Current.Keys) {
$currentLinks = $Current[$gpoName]
$baselineLinks = $Baseline[$gpoName]
# New GPO
if (-not $baselineLinks) {
Write-Log "New GPO detected: $gpoName"
foreach ($link in $currentLinks) {
Write-Log " - Linked to: $link"
}
continue
}
# Check for new links
foreach ($link in $currentLinks) {
if ($baselineLinks -notcontains $link) {
Write-Log "New link detected for GPO '$gpoName'"
Write-Log " - Linked to: $link"
}
}
# Check for removed links
foreach ($link in $baselineLinks) {
if ($currentLinks -notcontains $link) {
Write-Log "Link removed from GPO '$gpoName'"
Write-Log " - Unlinked from: $link"
}
}
}
# Check for deleted GPOs
foreach ($gpoName in $Baseline.Keys) {
if (-not $Current.ContainsKey($gpoName)) {
Write-Log "GPO no longer exists: $gpoName"
}
}
}
# Main script
try {
$currentLinks = Get-CurrentGPOLinks
if (Test-Path $baselinePath) {
Write-Log "Comparing against existing baseline..."
$baseline = Get-Content $baselinePath | ConvertFrom-Json
$baselineObject = @{}
# Convert baseline from JSON format back to hashtable
$baseline.PSObject.Properties | ForEach-Object {
$baselineObject[$_.Name] = $_.Value
}
Compare-GPOLinks -Baseline $baselineObject -Current $currentLinks
} else {
Write-Log "Creating new baseline..."
}
# Save current state as new baseline
$currentLinks | ConvertTo-Json | Set-Content $baselinePath
Write-Log "Baseline updated."
} catch {
Write-Log "Error: $_"
}
Step 2 : Normalise the Data
The log file is not very friendly for a report so we need to turn it from a log file to an actual CSV file that can be easily read, this will be normalising the data and this will convert this data into a nice easy to read file as below:
"Date","Action","GPOName","OUPath"
"2024-11-27","Added","Test Link Policy","bear.local/Servers/Script Testing"
"2024-11-27","Removed","Test Link Policy","bear.local/Servers/Script Testing"
"2024-11-27","Removed","Test Link Policy","bear.local/Servers/Script Testing"
"2024-11-27","Added","Test Link Policy Computer","bear.local/Servers/Script Testing"
Script : Normalise-Data.ps1
Note : This script will require the GPOLinkChanges.log file to work that needs to be in the same folder as the script is run from.
# Get content from log file
$logContent = Get-Content "GPOLinkChanges.log"
# Create empty array for results
$results = @()
# Process each line
for ($i = 0; $i -lt $logContent.Count; $i++) {
if ($logContent[$i] -match '^\[(.*?)\].*?(New link detected for|Link removed from) GPO .(.+?).$') {
$date = [datetime]::ParseExact($matches[1], 'yyyy-MM-dd HH:mm:ss', $null).ToString('yyyy-MM-dd')
$action = $matches[2] -replace 'New link detected for', 'Added' -replace 'Link removed from', 'Removed'
$gpoName = $matches[3]
# Get the OU path from next line
if ($logContent[$i + 1] -match '- (Linked to|Unlinked from): (.*)') {
$ouPath = $matches[2]
# Create custom object for CSV
$results += [PSCustomObject]@{
Date = $date
Action = $action
GPOName = $gpoName
OUPath = $ouPath
}
}
}
}
# Export to CSV
$results | Export-Csv -Path "GPO_LinkUpdates.csv" -NoTypeInformation
This is in a CSV file and that can be attached to a report which is handy and if this is all you need then you are done, however if you need more information on the perpetrator who performed this action read on.
However, do you want to know by who, are you curious?
Step 3: Event Extract from the Domain Controllers
We now need to query the operations log on the Domain Controller for who changed what with the UPN and GPO details, this will give you a CSV file that contains the following headers:
"TimeCreated","DC","SubjectUserName","TargetOU","GPOGUID","GPOName","ChangeType"
This then includes the "SubjectUserName" which for this example of the perpetrator, this log will includes all the history which will include all the unlinks and relinks and deletes however its not that helpful on it own.
"TimeCreated","DC","SubjectUserName","TargetOU","GPOGUID","GPOName","ChangeType"
"27/11/2024 15:34:33","beardc.bear.local","bear.local\gpo.editor","bear.local/Servers/Script Testing","298AC55A-27B7-4832-904F-41742D08B37A","Test Link Policy","Added"
"27/11/2024 15:16:41","beardc.bear.local","bear.local\gpo.editor","bear.local/Servers/Script Testing","298AC55A-27B7-4832-904F-41742D08B37A","Test Link Policy","Delete"
"27/11/2024 15:16:41","beardc.bear.local","bear.local\gpo.editor","bear.local/Servers/Script Testing","CC02D3FF-6288-46B9-85BB-EDD3D8A62754","Audit Policy","Added"
"27/11/2024 15:16:41","beardc.bear.local","bear.local\gpo.editor","bear.local/Servers/Script Testing","298AC55A-27B7-4832-904F-41742D08B37A","Test Link Policy","Delete"
"27/11/2024 13:54:19","beardc.bear.local","bear.local\gpo.editor","bear.local/Servers/Script Testing","CC02D3FF-6288-46B9-85BB-EDD3D8A62754","Audit Policy","Added"
"27/11/2024 13:54:19","beardc.bear.local","bear.local\gpo.editor","bear.local/Servers/Script Testing","298AC55A-27B7-4832-904F-41742D08B37A","Test Link Policy","Delete"
"27/11/2024 13:54:19","beardc.bear.local","bear.local\gpo.editor","bear.local/Servers/Script Testing","298AC55A-27B7-4832-904F-41742D08B37A","Test Link Policy","Added"
That shows here that I have added and deleted quite a few times with the relevant timestamp as well this comes from all the domain controllers but remember for GPO updates the PDC emulator does the job of tracking all the GPO changes which is why the server is always the same.
Script : Scanner.ps1
# Import required modules
Import-Module ActiveDirectory
Import-Module GroupPolicy
$xmlQuery = @"
<QueryList>
<Query Id="0" Path="Security">
<Select Path="Security">
*[System[(EventID=5136) and TimeCreated[timediff(@SystemTime) <= 86400000]]]
and
*[EventData[Data[@Name='AttributeLDAPDisplayName'] and (Data='gPLink')]]
and
*[EventData[Data[@Name='ObjectClass'] and (Data='organizationalUnit')]]
</Select>
</Query>
</QueryList>
"@
function Get-GPODisplayName {
param($guidString)
if ([string]::IsNullOrWhiteSpace($guidString)) {
return "Unknown GPO"
}
try {
$gpo = Get-GPO -Guid $guidString -ErrorAction Stop
return $gpo.DisplayName
} catch {
Write-Warning "Error retrieving GPO for GUID $guidString : $_.Exception.Message"
return "Unknown GPO ($guidString)"
}
}
function Parse-GPLink {
param($gpLinkString)
$links = @()
if ($gpLinkString -match '\[.+\]') {
$gpLinkString -split('\]\[') | ForEach-Object {
if ($_ -match 'LDAP://cn=\{(.+?)\}') {
$guid = $matches[1]
$links += @{
GUID = $guid
DisplayName = Get-GPODisplayName $guid
}
}
}
}
return $links
}
function Compare-GPLinks {
param($oldValue, $newValue)
$oldLinks = @(Parse-GPLink $oldValue)
$newLinks = @(Parse-GPLink $newValue)
# Find actually added GPOs (in new but not in old)
$added = $newLinks | Where-Object {
$guid = $_.GUID
-not ($oldLinks | Where-Object { $_.GUID -eq $guid })
}
# Find actually removed GPOs (in old but not in new)
$removed = $oldLinks | Where-Object {
$guid = $_.GUID
-not ($newLinks | Where-Object { $_.GUID -eq $guid })
}
return @{
Added = $added
Removed = $removed
}
}
$domainControllers = Get-ADDomainController -Filter * | Select-Object -ExpandProperty Hostname
$allEvents = @()
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()
$targetOU = $eventXML.Event.EventData.Data | Where-Object { $_.Name -eq 'ObjectDN' } | Select-Object -ExpandProperty '#text'
$oldValue = $eventXML.Event.EventData.Data | Where-Object { $_.Name -eq 'OldValue' } | Select-Object -ExpandProperty '#text'
$newValue = $eventXML.Event.EventData.Data | Where-Object { $_.Name -eq 'AttributeValue' } | Select-Object -ExpandProperty '#text'
$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'
# Only process if there's a difference between old and new values
if ($oldValue -ne $newValue) {
$changes = Compare-GPLinks $oldValue $newValue
$results = @()
foreach ($gpo in $changes.Added) {
$results += [PSCustomObject]@{
TimeCreated = $_.TimeCreated
DC = $dc
SubjectUserName = "$subjectDomainName\$subjectUserName"
TargetOU = $targetOU
GPOGUID = $gpo.GUID
GPOName = $gpo.DisplayName
ChangeType = "Added"
}
}
foreach ($gpo in $changes.Removed) {
$results += [PSCustomObject]@{
TimeCreated = $_.TimeCreated
DC = $dc
SubjectUserName = "$subjectDomainName\$subjectUserName"
TargetOU = $targetOU
GPOGUID = $gpo.GUID
GPOName = $gpo.DisplayName
ChangeType = "Removed"
}
}
$results
}
}
}
}
$csvPath = "GPOLinkChanges.csv"
$allEvents | Select-Object TimeCreated, DC, SubjectUserName, TargetOU, GPOGUID, GPOName, ChangeType |
Export-Csv -Path $csvPath -NoTypeInformation
Write-Host "Report has been exported to $csvPath"
This will then give you the files GPOLinkChanges.csv which has the important timestamps attached to it which we can use later on.
Step 4 : Apply User field to the CSV report
This step does some clever calculations on the files called GPOLinkChanges both the CSV and LOG formats to match the timestamp and the name of the GPO to then extract the UPN from the GPOLinkChanges to then add the Perpetrator field.
You need both of these files to perform this script magic, which will then return the output that looks like this, with the new field added:
"Date","Time","Action","GPOName","OUPath","Perpetrator","DC"
"2024-11-27","15:16:41","Added","Test Link Policy","bear.local/Servers/Script Testing","bear.local\gpo.editor","beardc.bear.local"
"2024-11-27","13:30:35","Added","Test Link Policy","bear.local/Servers/Script Testing","bear.local\gpo.editor","beardc.bear.local"
"2024-11-27","15:16:41","Removed","Test Link Policy","bear.local/Servers/Script Testing","bear.local\gpo.editor","beardc.bear.local"
"2024-11-27","13:54:19","Removed","Test Link Policy","bear.local/Servers/Script Testing","bear.local\gpo.editor","beardc.bear.local"
Script : CrossReference.ps1
Note : This script will cross reference the files created by other scripts in the article and are required to work.
# Script to correlate GPO changes with user information from logs
param(
[string]$linkLogPath = "GPOLinkChanges.log",
[string]$changesLogPath = "GPOLinkChanges.csv",
[string]$outputPath = "GPO_CorrelatedChanges.csv"
)
# Import the GPO changes CSV
$changesLog = Import-Csv $changesLogPath
Write-Host "Loaded $(($changesLog | Measure-Object).Count) entries from changes CSV"
# Read and parse the GPOLinkChanges.log file
$logContent = Get-Content $linkLogPath
$linkChanges = @()
# Extract change entries from log
for ($i = 0; $i -lt $logContent.Count; $i++) {
$line = $logContent[$i]
if ($line -match '^\[(.*?)\] (New link detected for GPO|Link removed from GPO)') {
$timestamp = $matches[1]
$action = if ($matches[2] -eq 'New link detected for GPO') { "Added" } else { "Removed" }
# Extract GPO name
$gpoName = $line -replace '.*GPO .(.+?).$', '$1'
# Get the path from the next line
$pathLine = $logContent[$i+1]
if ($pathLine -match '^\[(.*?)\].*?(Linked to: |Unlinked from: )(.+)$') {
$ouPath = $matches[3]
# Convert log timestamp to date only for comparison
$logDate = [DateTime]::ParseExact($timestamp, "yyyy-MM-dd HH:mm:ss", $null).Date
# Look for matches on the same day
$matchingChanges = $changesLog | Where-Object {
$csvDate = [DateTime]::ParseExact($_.TimeCreated.Split(' ')[0], "dd/MM/yyyy", $null).Date
$csvDate -eq $logDate -and $_.GPOName -eq $gpoName
}
# Take the first matching entry if multiple exist
$matchingChange = $matchingChanges | Select-Object -First 1
if ($matchingChange) {
Write-Host "Found matching entry for $gpoName on $($logDate.ToString('yyyy-MM-dd'))"
$linkChanges += [PSCustomObject]@{
'Date' = $logDate.ToString('yyyy-MM-dd') # Changed to date only
'Action' = $action
'GPOName' = $gpoName
'OUPath' = $ouPath
'Perpetrator' = $matchingChange.SubjectUserName
'DC' = $matchingChange.DC
}
} else {
Write-Host "No match found for $gpoName on $($logDate.ToString('yyyy-MM-dd'))"
}
}
}
}
# Export the correlated data to CSV
$linkChanges | Export-Csv -Path $outputPath -NoTypeInformation
# Display results
Write-Host "`nFound $($linkChanges.Count) correlated changes."
Write-Host "Results exported to: $outputPath"
Write-Host "`nCorrelated changes:"
$linkChanges | Format-Table -AutoSize