Public/Get-GPOChanges.ps1
|
function Get-GPOChanges { <# .SYNOPSIS Detects Group Policy Object changes within a specified timeframe. .DESCRIPTION Queries all GPOs for recent modifications by comparing ModificationTime, tracks version number changes to distinguish real edits from replication, and optionally checks for GPO link changes on OUs. Parses event logs for GPO-related security events on domain controllers. .PARAMETER HoursBack Number of hours to look back for changes. Default is 24. Range: 1-720. .PARAMETER IncludeLinkChanges Switch to include GPO link changes on OUs. .EXAMPLE Get-GPOChanges -HoursBack 48 -IncludeLinkChanges Returns all GPO and link changes from the last 48 hours. #> [CmdletBinding()] param( [Parameter()] [ValidateRange(1, 720)] [int]$HoursBack = 24, [Parameter()] [switch]$IncludeLinkChanges ) begin { $Changes = [System.Collections.Generic.List[PSCustomObject]]::new() $CutoffTime = (Get-Date).AddHours(-$HoursBack) } process { # ---------------------------------------------------------------- # Phase 1: Query all GPOs and find recently modified ones # ---------------------------------------------------------------- Write-Verbose "Querying GPOs modified in the last $HoursBack hours..." try { $AllGPOs = Get-GPO -All -ErrorAction Stop foreach ($GPO in $AllGPOs) { if ($GPO.ModificationTime -ge $CutoffTime) { # Determine if this is a newly created GPO $IsNew = ($GPO.CreationTime -ge $CutoffTime) $ChangeTypeValue = if ($IsNew) { 'Created' } else { 'Modified' } $SeverityValue = if ($IsNew) { 'Medium' } else { 'Low' } # Compute version details (user and computer halves) $UserVersion = $GPO.User.DSVersion $ComputerVersion = $GPO.Computer.DSVersion $VersionString = "User: v$UserVersion, Computer: v$ComputerVersion" # Check if this is a high-impact GPO $HighImpactGPOs = @('Default Domain Policy', 'Default Domain Controllers Policy') if ($HighImpactGPOs -contains $GPO.DisplayName) { $SeverityValue = 'High' } # Try to determine who modified the GPO via file system owner $ModifiedBy = '' try { $Domain = (Get-ADDomain -ErrorAction Stop).DNSRoot $GpoPath = "\\$Domain\SYSVOL\$Domain\Policies\{$($GPO.Id)}" if (Test-Path -Path $GpoPath -ErrorAction SilentlyContinue) { $Acl = Get-Acl -Path $GpoPath -ErrorAction SilentlyContinue if ($Acl) { $ModifiedBy = $Acl.Owner } } } catch { Write-Verbose "Could not determine GPO file owner: $_" } # Find linked OUs for this GPO $LinkedOUs = [System.Collections.Generic.List[string]]::new() try { $GpoXml = [xml](Get-GPOReport -Guid $GPO.Id -ReportType Xml -ErrorAction Stop) $NsMgr = New-Object System.Xml.XmlNamespaceManager($GpoXml.NameTable) $NsMgr.AddNamespace('gpo', 'http://www.microsoft.com/GroupPolicy/Settings') $LinkNodes = $GpoXml.SelectNodes('//gpo:LinksTo', $NsMgr) foreach ($Link in $LinkNodes) { $SomPath = $Link.SelectSingleNode('gpo:SOMPath', $NsMgr) if ($SomPath) { $LinkedOUs.Add($SomPath.InnerText) } } } catch { Write-Verbose "Could not retrieve GPO report for link info: $_" } $Detail = if ($IsNew) { "New GPO '$($GPO.DisplayName)' created. $VersionString" } else { "GPO '$($GPO.DisplayName)' modified. $VersionString" } if ($LinkedOUs.Count -gt 0) { $Detail += " Linked to: $($LinkedOUs -join '; ')" } $Changes.Add([PSCustomObject]@{ ChangeTime = $GPO.ModificationTime ChangeType = $ChangeTypeValue Category = 'GroupPolicy' ObjectName = $GPO.DisplayName ObjectType = 'GPO' ChangedBy = $ModifiedBy OldValue = '' NewValue = $VersionString Detail = $Detail Source = "GPO Query (ID: $($GPO.Id))" Severity = $SeverityValue GPOId = $GPO.Id VersionChange = $VersionString LinkedOUs = ($LinkedOUs -join '; ') }) } } } catch { Write-Warning "Failed to query GPOs: $_" } # ---------------------------------------------------------------- # Phase 2: Check for GPO link changes on OUs # ---------------------------------------------------------------- if ($IncludeLinkChanges) { Write-Verbose "Checking for GPO link changes on OUs..." try { $OUs = Get-ADOrganizationalUnit -Filter * -Properties gpLink, whenChanged -ErrorAction Stop foreach ($OU in $OUs) { if ($OU.whenChanged -ge $CutoffTime -and $OU.gpLink) { # Parse the gpLink attribute to extract linked GPO GUIDs $LinkPattern = '\[LDAP://cn=\{([0-9a-fA-F\-]+)\},cn=policies,cn=system,DC=[^\]]+\]' $Matches = [regex]::Matches($OU.gpLink, $LinkPattern) $LinkedGpoIds = $Matches | ForEach-Object { $_.Groups[1].Value } foreach ($GpoGuid in $LinkedGpoIds) { try { $LinkedGpo = Get-GPO -Guid $GpoGuid -ErrorAction Stop $GpoName = $LinkedGpo.DisplayName } catch { $GpoName = "GPO {$GpoGuid}" } $Changes.Add([PSCustomObject]@{ ChangeTime = $OU.whenChanged ChangeType = 'LinkChanged' Category = 'GroupPolicy' ObjectName = $GpoName ObjectType = 'GPOLink' ChangedBy = '' OldValue = '' NewValue = $OU.DistinguishedName Detail = "GPO link on OU '$($OU.Name)' was changed. GPO: $GpoName" Source = "OU gpLink attribute ($($OU.DistinguishedName))" Severity = 'Medium' GPOId = $GpoGuid VersionChange = '' LinkedOUs = $OU.DistinguishedName }) } } } } catch { Write-Warning "Failed to query OU link changes: $_" } } # ---------------------------------------------------------------- # Phase 3: Parse event logs for GPO-related events # ---------------------------------------------------------------- Write-Verbose "Querying event logs for GPO-related events..." # Event ID 5136 = Directory Service Changes (covers GPO object modifications) # Event ID 5137 = Directory Service object created # Event ID 5141 = Directory Service object deleted $GpoEventIds = @(5136, 5137, 5141) try { $DomainControllers = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName } catch { Write-Warning "Failed to enumerate domain controllers for GPO event log query: $_" $DomainControllers = @() } foreach ($DC in $DomainControllers) { try { $FilterHash = @{ LogName = 'Security' Id = $GpoEventIds StartTime = $CutoffTime } $Events = Get-WinEvent -ComputerName $DC -FilterHashtable $FilterHash -ErrorAction Stop foreach ($Event in $Events) { # Only process events related to Group Policy containers if ($Event.Message -notmatch 'groupPolicyContainer|cn=policies' -and $Event.Message -notmatch 'Group Policy') { continue } $EventData = [xml]$Event.ToXml() $DataItems = @{} $EventData.Event.EventData.Data | ForEach-Object { $DataItems[$_.Name] = $_.'#text' } $ChangedByUser = if ($DataItems['SubjectUserName']) { "$($DataItems['SubjectDomainName'])\$($DataItems['SubjectUserName'])" } else { 'Unknown' } $ObjectDN = $DataItems['ObjectDN'] $GpoNameFromEvent = '' if ($ObjectDN -match 'cn=\{([0-9a-fA-F\-]+)\},cn=policies') { $ExtractedGuid = $Matches[1] try { $GpoFromEvent = Get-GPO -Guid $ExtractedGuid -ErrorAction Stop $GpoNameFromEvent = $GpoFromEvent.DisplayName } catch { $GpoNameFromEvent = "GPO {$ExtractedGuid}" } } if (-not $GpoNameFromEvent) { continue } $EventChangeType = switch ($Event.Id) { 5136 { 'Modified' } 5137 { 'Created' } 5141 { 'Deleted' } } $EventSeverity = switch ($Event.Id) { 5136 { 'Low' } 5137 { 'Medium' } 5141 { 'High' } } $Changes.Add([PSCustomObject]@{ ChangeTime = $Event.TimeCreated ChangeType = $EventChangeType Category = 'GroupPolicy' ObjectName = $GpoNameFromEvent ObjectType = 'GPO' ChangedBy = $ChangedByUser OldValue = $DataItems['OldValue'] NewValue = $DataItems['NewValue'] Detail = "GPO '$GpoNameFromEvent' $EventChangeType by $ChangedByUser (Event $($Event.Id) on $DC)" Source = "Security Event $($Event.Id) on $DC" Severity = $EventSeverity GPOId = if ($ExtractedGuid) { $ExtractedGuid } else { '' } VersionChange = '' LinkedOUs = '' }) } } catch { if ($_.Exception.Message -match 'No events were found') { Write-Verbose " No GPO events on $DC" } else { Write-Warning "Failed to query GPO events on ${DC}: $_" } } } } end { # Deduplicate: prefer event log entries because they carry ChangedBy $Deduplicated = [System.Collections.Generic.List[PSCustomObject]]::new() $Seen = @{} foreach ($Change in ($Changes | Sort-Object ChangeTime -Descending)) { $Key = "$($Change.ChangeType)|$($Change.ObjectName)|$($Change.ChangeTime.ToString('yyyyMMddHHmm'))" if (-not $Seen.ContainsKey($Key)) { $Seen[$Key] = $true $Deduplicated.Add($Change) } } Write-Verbose "Found $($Deduplicated.Count) GPO changes in the last $HoursBack hours." return $Deduplicated } } |