Private/ADMonitor/Detections/Test-ADReplicationAnomaly.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Test-ADReplicationAnomaly {
    [CmdletBinding()]
    param(
        [array]$ACLChanges = @()
    )

    $indicators = [System.Collections.Generic.List[PSCustomObject]]::new()

    if ($ACLChanges.Count -eq 0) { return @() }

    # Detect when replication-related permissions are granted to non-standard accounts
    # This overlaps with DCSync but catches broader replication anomalies
    $replicationGuids = @(
        '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2',    # DS-Replication-Get-Changes
        '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2',    # DS-Replication-Get-Changes-All
        '89e95b76-444d-4c62-991a-0facbeda640c',     # DS-Replication-Get-Changes-In-Filtered-Set
        '1131f6ab-9c07-11d1-f79f-00c04fc2dcd2',    # DS-Replication-Manage-Topology
        '1131f6ac-9c07-11d1-f79f-00c04fc2dcd2'     # DS-Replication-Synchronize
    )

    $replicationGuidNames = @{
        '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2' = 'DS-Replication-Get-Changes'
        '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2' = 'DS-Replication-Get-Changes-All'
        '89e95b76-444d-4c62-991a-0facbeda640c'  = 'DS-Replication-Get-Changes-In-Filtered-Set'
        '1131f6ab-9c07-11d1-f79f-00c04fc2dcd2'  = 'DS-Replication-Manage-Topology'
        '1131f6ac-9c07-11d1-f79f-00c04fc2dcd2'  = 'DS-Replication-Synchronize'
    }

    # Standard accounts that should have replication rights
    $expectedReplicationAccounts = @(
        'Domain Controllers', 'Enterprise Domain Controllers',
        'ENTERPRISE DOMAIN CONTROLLERS', 'SYSTEM',
        'S-1-5-18', 'S-1-5-9'
    )

    $addedACEs = @($ACLChanges | Where-Object { $_.ChangeType -eq 'Added' })

    $replicationChanges = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($ace in $addedACEs) {
        $rights = if ($ace.ContainsKey('Rights')) { $ace.Rights } else { '' }
        $objectType = if ($ace.ContainsKey('objectType')) { $ace.objectType } else { '' }
        $identity = if ($ace.ContainsKey('Identity')) { $ace.Identity } else { '' }

        if ($rights -notmatch 'ExtendedRight|GenericAll') { continue }

        $isReplication = $false
        $grantedRight = ''

        foreach ($guid in $replicationGuids) {
            if ($objectType -eq $guid) {
                $isReplication = $true
                $grantedRight = if ($replicationGuidNames.ContainsKey($guid)) { $replicationGuidNames[$guid] } else { $guid }
                break
            }
        }

        if (-not $isReplication) { continue }

        # Skip expected replication accounts
        $isExpected = $false
        foreach ($expected in $expectedReplicationAccounts) {
            if ($identity -eq $expected -or $identity -like "*\$expected" -or $identity -match "$expected$") {
                $isExpected = $true
                break
            }
        }
        if ($isExpected) { continue }

        $replicationChanges.Add(@{
            Identity    = $identity
            Right       = $grantedRight
            ObjectDN    = if ($ace.ContainsKey('ObjectDN')) { $ace.ObjectDN } else { '' }
        })
    }

    if ($replicationChanges.Count -eq 0) { return @() }

    $identities = @($replicationChanges | ForEach-Object { $_.Identity } | Sort-Object -Unique)
    $detectionId = "adReplicationAnomaly_$(($identities -join '_') -replace '[\\\/\s]', '_')"

    $indicators.Add([PSCustomObject]@{
        DetectionId   = $detectionId
        DetectionName = 'Replication Permission Anomaly'
        DetectionType = 'adReplicationAnomaly'
        Description   = "REPLICATION ANOMALY - Replication rights granted to non-standard account(s): $($identities -join ', '). This may indicate DCSync attack preparation or unauthorized replication topology changes."
        Details       = @{
            Changes = @($replicationChanges)
        }
        Count         = $replicationChanges.Count
        Score         = 0
        Severity      = ''
    })

    return @($indicators)
}