Public/Get-ADChanges.ps1

function Get-ADChanges {
    <#
    .SYNOPSIS
        Detects Active Directory changes using replication metadata and event logs.
 
    .DESCRIPTION
        Queries AD objects modified within the specified timeframe and parses Security
        event logs on domain controllers for change events. Tracks who made each change,
        what changed, when, and from which workstation.
 
    .PARAMETER HoursBack
        Number of hours to look back for changes. Default is 24. Range: 1-720.
 
    .PARAMETER SearchBase
        Optional OU distinguished name to scope the search.
 
    .PARAMETER ChangeType
        Filter by object type: All, Users, Groups, Computers, OUs. Default is All.
 
    .EXAMPLE
        Get-ADChanges -HoursBack 48 -ChangeType Groups
        Returns all group changes from the last 48 hours.
 
    .EXAMPLE
        Get-ADChanges -SearchBase "OU=Finance,DC=contoso,DC=com"
        Returns all AD changes within the Finance OU from the last 24 hours.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [ValidateRange(1, 720)]
        [int]$HoursBack = 24,

        [Parameter()]
        [string]$SearchBase,

        [Parameter()]
        [ValidateSet('All', 'Users', 'Groups', 'Computers', 'OUs')]
        [string]$ChangeType = 'All'
    )

    begin {
        $Changes = [System.Collections.Generic.List[PSCustomObject]]::new()
        $CutoffTime = (Get-Date).AddHours(-$HoursBack)
        $CutoffTimeStr = $CutoffTime.ToString('yyyyMMddHHmmss.0Z')

        # Map ChangeType to LDAP objectClass filters
        $ObjectClassFilter = switch ($ChangeType) {
            'Users'     { '(objectClass=user)(objectCategory=person)' }
            'Groups'    { '(objectClass=group)' }
            'Computers' { '(objectClass=computer)' }
            'OUs'       { '(objectClass=organizationalUnit)' }
            'All'       { '(|(objectClass=user)(objectClass=group)(objectClass=computer)(objectClass=organizationalUnit))' }
        }

        # Event IDs we care about for AD changes
        $EventIdMap = @{
            4720 = @{ ChangeType = 'Created';       ObjectType = 'User';  Severity = 'Medium' }
            4726 = @{ ChangeType = 'Deleted';        ObjectType = 'User';  Severity = 'High' }
            4738 = @{ ChangeType = 'Modified';       ObjectType = 'User';  Severity = 'Low' }
            4732 = @{ ChangeType = 'MemberAdded';    ObjectType = 'Group'; Severity = 'Medium' }
            4733 = @{ ChangeType = 'MemberRemoved';  ObjectType = 'Group'; Severity = 'Medium' }
            4728 = @{ ChangeType = 'MemberAdded';    ObjectType = 'Group'; Severity = 'Medium' }
            4729 = @{ ChangeType = 'MemberRemoved';  ObjectType = 'Group'; Severity = 'Medium' }
            4756 = @{ ChangeType = 'MemberAdded';    ObjectType = 'Group'; Severity = 'Medium' }
            4757 = @{ ChangeType = 'MemberRemoved';  ObjectType = 'Group'; Severity = 'Medium' }
        }

        # Filter event IDs based on ChangeType
        $EventIds = switch ($ChangeType) {
            'Users'     { @(4720, 4726, 4738) }
            'Groups'    { @(4732, 4733, 4728, 4729, 4756, 4757) }
            'Computers' { @() }
            'OUs'       { @() }
            'All'       { @(4720, 4726, 4738, 4732, 4733, 4728, 4729, 4756, 4757) }
        }
    }

    process {
        # ----------------------------------------------------------------
        # Phase 1: Query AD objects modified within the timeframe
        # ----------------------------------------------------------------
        Write-Verbose "Querying AD objects modified in the last $HoursBack hours..."

        $GetADObjectParams = @{
            LDAPFilter = "(&${ObjectClassFilter}(whenChanged>=${CutoffTimeStr}))"
            Properties = @('whenChanged', 'whenCreated', 'objectClass', 'distinguishedName',
                           'name', 'description', 'modifyTimeStamp', 'msDS-ReplAttributeMetaData')
        }
        if ($SearchBase) {
            $GetADObjectParams['SearchBase'] = $SearchBase
        }

        try {
            $ModifiedObjects = Get-ADObject @GetADObjectParams

            foreach ($Obj in $ModifiedObjects) {
                # Determine the object type from objectClass
                $ObjType = switch -Wildcard ($Obj.objectClass) {
                    'user'                   { 'User' }
                    'group'                  { 'Group' }
                    'computer'               { 'Computer' }
                    'organizationalUnit'     { 'OU' }
                    default                  { 'Unknown' }
                }

                # Determine if this was a creation or modification
                $IsCreation = ($Obj.whenCreated -ge $CutoffTime)
                $ChangeTypeValue = if ($IsCreation) { 'Created' } else { 'Modified' }
                $SeverityValue = if ($IsCreation) { 'Medium' } else { 'Low' }

                # Build detail string from replication metadata if available
                $Detail = ''
                if ($Obj.'msDS-ReplAttributeMetaData') {
                    try {
                        $MetaXml = [xml]"<root>$($Obj.'msDS-ReplAttributeMetaData')</root>"
                        $RecentAttrs = $MetaXml.root.DS_REPL_ATTR_META_DATA |
                            Where-Object { $_.ftimeLastOriginatingChange -ge $CutoffTimeStr } |
                            Select-Object -ExpandProperty pszAttributeName -ErrorAction SilentlyContinue
                        if ($RecentAttrs) {
                            $Detail = "Attributes changed: $($RecentAttrs -join ', ')"
                        }
                    }
                    catch {
                        $Detail = "Modified (replication metadata not parseable)"
                    }
                }

                $Changes.Add([PSCustomObject]@{
                    ChangeTime      = $Obj.whenChanged
                    ChangeType      = $ChangeTypeValue
                    Category        = 'ActiveDirectory'
                    ObjectName      = $Obj.Name
                    ObjectType      = $ObjType
                    ChangedBy       = ''
                    OldValue        = ''
                    NewValue        = ''
                    Detail          = if ($Detail) { $Detail } else { "$ChangeTypeValue $ObjType '$($Obj.Name)'" }
                    Source          = "AD Object Query ($($Obj.DistinguishedName))"
                    Severity        = $SeverityValue
                    DomainController = ''
                })
            }
        }
        catch {
            Write-Warning "Failed to query AD objects: $_"
        }

        # ----------------------------------------------------------------
        # Phase 2: Parse Security event logs on domain controllers
        # ----------------------------------------------------------------
        if ($EventIds.Count -gt 0) {
            Write-Verbose "Querying Security event logs on domain controllers..."

            try {
                $DomainControllers = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName
            }
            catch {
                Write-Warning "Failed to enumerate domain controllers: $_. Trying local DC."
                try {
                    $DomainControllers = @((Get-ADDomainController -Discover).HostName)
                }
                catch {
                    Write-Warning "Cannot discover any domain controller: $_"
                    $DomainControllers = @()
                }
            }

            foreach ($DC in $DomainControllers) {
                Write-Verbose " Querying events on $DC..."
                try {
                    $FilterHash = @{
                        LogName   = 'Security'
                        Id        = $EventIds
                        StartTime = $CutoffTime
                    }

                    $Events = Get-WinEvent -ComputerName $DC -FilterHashtable $FilterHash -ErrorAction Stop

                    foreach ($Event in $Events) {
                        $EventData = [xml]$Event.ToXml()
                        $DataItems = @{}
                        $EventData.Event.EventData.Data | ForEach-Object {
                            $DataItems[$_.Name] = $_.'#text'
                        }

                        $EventInfo = $EventIdMap[$Event.Id]
                        if (-not $EventInfo) { continue }

                        # Skip if ChangeType filter doesn't match
                        if ($ChangeType -ne 'All') {
                            $MatchType = switch ($ChangeType) {
                                'Users'  { 'User' }
                                'Groups' { 'Group' }
                                default  { $null }
                            }
                            if ($MatchType -and $EventInfo.ObjectType -ne $MatchType) { continue }
                        }

                        # Extract fields from event data
                        $ChangedByUser = if ($DataItems['SubjectUserName']) {
                            "$($DataItems['SubjectDomainName'])\$($DataItems['SubjectUserName'])"
                        } else { 'Unknown' }

                        $TargetName = $DataItems['TargetUserName']
                        if (-not $TargetName) { $TargetName = $DataItems['TargetDomainName'] }

                        # For group membership events, capture the member being added/removed
                        $MemberName = $DataItems['MemberName']
                        $MemberSid  = $DataItems['MemberSid']

                        # Build detail string
                        $EventDetail = switch ($Event.Id) {
                            4720 { "User account '$TargetName' created by $ChangedByUser" }
                            4726 { "User account '$TargetName' deleted by $ChangedByUser" }
                            4738 { "User account '$TargetName' modified by $ChangedByUser" }
                            { $_ -in 4732, 4728, 4756 } {
                                $GroupName = $DataItems['TargetUserName']
                                "Member '$MemberName' added to group '$GroupName' by $ChangedByUser"
                            }
                            { $_ -in 4733, 4729, 4757 } {
                                $GroupName = $DataItems['TargetUserName']
                                "Member '$MemberName' removed from group '$GroupName' by $ChangedByUser"
                            }
                            default { $Event.Message }
                        }

                        # Elevate severity for sensitive group changes
                        $EventSeverity = $EventInfo.Severity
                        $SensitiveGroups = @('Domain Admins', 'Enterprise Admins', 'Schema Admins',
                                             'Administrators', 'Account Operators', 'Backup Operators')
                        $GroupTarget = $DataItems['TargetUserName']
                        if ($GroupTarget -and $SensitiveGroups -contains $GroupTarget) {
                            $EventSeverity = 'Critical'
                        }

                        # Determine object name
                        $ObjName = if ($Event.Id -in 4732, 4733, 4728, 4729, 4756, 4757) {
                            $DataItems['TargetUserName']
                        } else {
                            $TargetName
                        }

                        $Changes.Add([PSCustomObject]@{
                            ChangeTime       = $Event.TimeCreated
                            ChangeType       = $EventInfo.ChangeType
                            Category         = 'ActiveDirectory'
                            ObjectName       = $ObjName
                            ObjectType       = $EventInfo.ObjectType
                            ChangedBy        = $ChangedByUser
                            OldValue         = ''
                            NewValue         = if ($MemberName) { $MemberName } else { '' }
                            Detail           = $EventDetail
                            Source           = "Security Event $($Event.Id) on $DC"
                            Severity         = $EventSeverity
                            DomainController = $DC
                        })
                    }
                }
                catch [System.Diagnostics.Eventing.Reader.EventLogNotFoundException] {
                    Write-Warning "Security event log not accessible on $DC"
                }
                catch {
                    if ($_.Exception.Message -match 'No events were found') {
                        Write-Verbose " No matching events on $DC"
                    }
                    else {
                        Write-Warning "Failed to query events on ${DC}: $_"
                    }
                }
            }
        }
    }

    end {
        # Deduplicate changes that appear in both AD query and event log
        $Deduplicated = [System.Collections.Generic.List[PSCustomObject]]::new()
        $Seen = @{}

        foreach ($Change in ($Changes | Sort-Object ChangeTime -Descending)) {
            $Key = "$($Change.ChangeType)|$($Change.ObjectName)|$($Change.ObjectType)|$($Change.ChangeTime.ToString('yyyyMMddHHmm'))"
            if (-not $Seen.ContainsKey($Key)) {
                $Seen[$Key] = $true
                # Prefer event log entries (they have ChangedBy info)
                $Deduplicated.Add($Change)
            }
            elseif ($Change.ChangedBy -and -not ($Deduplicated | Where-Object {
                "$($_.ChangeType)|$($_.ObjectName)|$($_.ObjectType)|$($_.ChangeTime.ToString('yyyyMMddHHmm'))" -eq $Key -and $_.ChangedBy
            })) {
                # Replace the existing entry if this one has ChangedBy info
                $Index = 0
                for ($i = 0; $i -lt $Deduplicated.Count; $i++) {
                    $Existing = $Deduplicated[$i]
                    $ExistingKey = "$($Existing.ChangeType)|$($Existing.ObjectName)|$($Existing.ObjectType)|$($Existing.ChangeTime.ToString('yyyyMMddHHmm'))"
                    if ($ExistingKey -eq $Key) { $Index = $i; break }
                }
                $Deduplicated[$Index] = $Change
            }
        }

        Write-Verbose "Found $($Deduplicated.Count) AD changes in the last $HoursBack hours."
        return $Deduplicated
    }
}