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
    }
}