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