Private/AD/Core/Get-ADReplicationHealth.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
#
# Get-ADReplicationHealth
# -------------------------------------------------------------------------------
# Collects AD replication health for ADDOM-007. Populates a hashtable consumed as
# $AuditData.Domain.ReplicationHealth:
# @{ Partners = @(...); Failures = @(...); SingleDC = $bool; Source = '...' }
#
# Honesty contract (project rule #1):
# * Prefers the ActiveDirectory module (Get-ADReplicationPartnerMetadata /
# Get-ADReplicationFailure), guarded with Get-Command.
# * A single-DC forest has NO replication partners. That is healthy, not
# unknown — we return Failures=@() / SingleDC=$true so ADDOM-007 PASSes with
# "single DC, no replication topology".
# * If neither the AD module nor repadmin is usable, returns $null so
# ADDOM-007 SKIPs ("Not Assessed") instead of falsely PASSing.
#
# References: MITRE ATT&CK T1207 (Rogue Domain Controller / replication abuse);
# CIS Microsoft AD benchmark (monitor replication health).
# -------------------------------------------------------------------------------
function Get-ADReplicationHealth {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Connection,

        # Number of DCs already enumerated (lets us recognise a single-DC forest
        # even when the AD module / repadmin can't be used).
        [int]$DomainControllerCount = 0,

        [switch]$Quiet
    )

    $server = $Connection.Server
    if (-not $server) {
        try { $server = $Connection.RootDSE.Properties['dnsHostName'][0].ToString() } catch { }
    }

    # ── Path 1: ActiveDirectory module ────────────────────────────────────────
    $haveADModule = $false
    try {
        if (Get-Module -ListAvailable -Name ActiveDirectory -ErrorAction SilentlyContinue) {
            Import-Module ActiveDirectory -ErrorAction Stop -Verbose:$false | Out-Null
            $haveADModule = (Get-Command -Name Get-ADReplicationPartnerMetadata -ErrorAction SilentlyContinue) -and
                            (Get-Command -Name Get-ADReplicationFailure -ErrorAction SilentlyContinue)
        }
    } catch {
        $haveADModule = $false
    }

    if ($haveADModule) {
        try {
            if (-not $Quiet) {
                Write-ProgressLine -Phase RECON -Message 'Reading replication partner metadata (ActiveDirectory module)'
            }

            $partners = @()
            $failures = @()

            $scopeArgs = @{ ErrorAction = 'Stop' }
            if ($server) { $scopeArgs['Target'] = $server } else { $scopeArgs['Target'] = $Connection.DomainDN; $scopeArgs['Scope'] = 'Domain' }

            try {
                $partners = @(Get-ADReplicationPartnerMetadata @scopeArgs |
                    ForEach-Object {
                        @{
                            Server               = $_.Server
                            Partner              = $_.Partner
                            LastReplicationResult = $_.LastReplicationResult
                            LastReplicationSuccess = $_.LastReplicationSuccess
                            ConsecutiveFailures  = $_.ConsecutiveReplicationFailures
                        }
                    })
            } catch {
                Write-Verbose "Get-ADReplicationPartnerMetadata failed: $_"
            }

            try {
                $failArgs = @{ ErrorAction = 'Stop' }
                if ($server) { $failArgs['Target'] = $server } else { $failArgs['Scope'] = 'Domain'; $failArgs['Target'] = $Connection.DomainDN }
                $failures = @(Get-ADReplicationFailure @failArgs |
                    ForEach-Object {
                        @{
                            Server       = $_.Server
                            Partner      = $_.Partner
                            FailureType  = "$($_.FailureType)"
                            FailureCount = $_.FailureCount
                            LastError    = $_.LastError
                        }
                    } | Where-Object { $_.FailureCount -gt 0 })
            } catch {
                Write-Verbose "Get-ADReplicationFailure failed: $_"
            }

            # Treat non-zero LastReplicationResult as a failure too.
            $partnerFailures = @($partners | Where-Object {
                $null -ne $_.LastReplicationResult -and [int]$_.LastReplicationResult -ne 0
            })

            $allFailures = @($failures) + @($partnerFailures | ForEach-Object {
                @{
                    Server      = $_.Server
                    Partner     = $_.Partner
                    FailureType = "LastReplicationResult=$($_.LastReplicationResult)"
                    FailureCount = $_.ConsecutiveFailures
                }
            })

            $singleDC = ($partners.Count -eq 0)

            if (-not $Quiet) {
                Write-ProgressLine -Phase RECON -Message ("Replication: {0} partner link(s), {1} failure(s){2}" -f `
                    $partners.Count, $allFailures.Count, $(if ($singleDC) { ' (single DC)' } else { '' }))
            }

            return @{
                Partners = @($partners)
                Failures = @($allFailures)
                SingleDC = $singleDC
                Source   = 'ActiveDirectory module'
            }
        } catch {
            Write-Verbose "ActiveDirectory replication collection failed: $_"
            # fall through to repadmin / single-DC inference
        }
    }

    # ── Path 2: repadmin /replsummary (if present) ────────────────────────────
    $repadmin = Get-Command -Name repadmin.exe -ErrorAction SilentlyContinue
    if (-not $repadmin) { $repadmin = Get-Command -Name repadmin -ErrorAction SilentlyContinue }
    if ($repadmin) {
        try {
            if (-not $Quiet) {
                Write-ProgressLine -Phase RECON -Message 'Reading replication summary (repadmin /replsummary)'
            }
            $raw = & $repadmin.Source '/replsummary' 2>$null
            $failures = [System.Collections.Generic.List[hashtable]]::new()
            foreach ($line in @($raw)) {
                # repadmin marks failed deltas with a non-zero "fails/total" ratio.
                if ($line -match '^\s*(\S+)\s+(\d+)\s*/\s*(\d+)\s+(\d+)') {
                    $fails = [int]$Matches[2]
                    if ($fails -gt 0) {
                        $failures.Add(@{
                            Server       = $Matches[1]
                            FailureCount = $fails
                            FailureType  = 'repadmin /replsummary delta failure'
                        })
                    }
                }
            }
            return @{
                Partners = @()
                Failures = @($failures)
                SingleDC = ($DomainControllerCount -le 1)
                Source   = 'repadmin /replsummary'
            }
        } catch {
            Write-Verbose "repadmin replication collection failed: $_"
        }
    }

    # ── Path 3: single-DC inference (no tooling, but we know the DC count) ─────
    # If we positively know there is exactly one DC, there is no replication
    # topology to fail — that is a healthy state we CAN assert.
    if ($DomainControllerCount -eq 1) {
        if (-not $Quiet) {
            Write-ProgressLine -Phase RECON -Message 'Single domain controller detected — no replication topology to assess (healthy)'
        }
        return @{
            Partners = @()
            Failures = @()
            SingleDC = $true
            Source   = 'Single-DC inference (no replication partners)'
        }
    }

    # Neither tooling available and more than one DC (or unknown count): we cannot
    # honestly assess replication. Return $null so ADDOM-007 SKIPs.
    Write-Verbose 'No replication-health source available (ActiveDirectory module / repadmin); returning $null.'
    return $null
}