Private/Resolvers/Expand-AdGroupMember.ps1

function ConvertTo-DomainFqdnFromDn {
    # Pull the DC= components out of a distinguished name and join them into
    # an FQDN. Used to discover a member's actual home domain — Get-ADGroupMember
    # returns cross-trust members with their real DN, but the parent's netbios
    # is the wrong source for the member's Account / domain attribution.
    [CmdletBinding()]
    [OutputType([string])]
    param([AllowEmptyString()][AllowNull()][string]$Dn)
    if ([string]::IsNullOrWhiteSpace($Dn)) { return $null }
    $parts = [regex]::Matches($Dn, '(?i)DC=([^,]+)') | ForEach-Object { $_.Groups[1].Value }
    if ($parts.Count -eq 0) { return $null }
    ($parts -join '.').ToLowerInvariant()
}

function Get-NetbiosFromFqdn {
    # Reverse-walk a netbios → FQDN cache to find the netbios for a given FQDN.
    # Returns $null when not found — callers fall back to the parent's netbios.
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [AllowEmptyString()][AllowNull()][string]$Fqdn,
        [Parameter(Mandatory)][hashtable]$Cache
    )
    if ([string]::IsNullOrWhiteSpace($Fqdn)) { return $null }
    $target = $Fqdn.ToUpperInvariant()
    foreach ($k in @($Cache.Keys)) {
        $v = $Cache[$k]
        if ($null -ne $v -and ([string]$v).ToUpperInvariant() -eq $target) {
            return [string]$k
        }
    }
    return $null
}

function Expand-AdGroupMember {
    # Recursively walk the membership of an AD group, emitting one row per
    # nested group and per leaf principal. Each emitted entry carries:
    # * Sid / SamAccountName / Name / DistinguishedName from AD
    # * Domain — FQDN of the principal's home domain (derived from its DN,
    # so cross-trust members are attributed correctly)
    # * Account — DOMAIN\sam form (NetBIOS resolved from DomainCache when
    # possible; falls back to the parent group's netbios when the
    # member's home domain isn't in the cache)
    # * Path — chain of group accounts from outermost down to (but not
    # including) this row, so reports can show "inherited via X → Y → Z"
    #
    # We deliberately do NOT pass -Recursive to Get-ADGroupMember. -Recursive
    # silently flattens in-forest nested groups, hiding intermediate groups
    # in the chain. By listing direct members only and recursing ourselves,
    # every level of nesting shows up in Path. The Visited set guards against
    # cycles. -SkipDefaultGroupExpansion stops recursion through huge well-
    # known groups (Domain Admins, etc.); the group entry is still emitted.
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][string]$Sid,
        [Parameter(Mandatory)][string]$Account,
        [Parameter(Mandatory)][hashtable]$DomainCache,
        [switch]$SkipDefaultGroupExpansion,
        [hashtable]$Visited = @{},
        [string[]]$Chain = @(),
        # Optional FQDN(upper) → PSCredential map. Domains in the map use that
        # credential for AD calls instead of current Kerberos — see
        # Resolve-AdPrincipal for the cross-domain contract.
        [hashtable]$CredentialMap = @{}
    )

    if ($Visited.ContainsKey($Sid)) { return @() }
    $Visited[$Sid] = $true

    $netbios = ($Account -split '\\', 2)[0]
    $fqdn    = Resolve-DomainFqdn -NetbiosName $netbios -Cache $DomainCache
    if (-not $fqdn) { return @() }

    $newChain = @($Chain) + @($Account)

    $credKey = $fqdn.ToUpperInvariant()
    $adArgs  = @{ Server = $fqdn; ErrorAction = 'Stop' }
    if ($CredentialMap -and $CredentialMap.ContainsKey($credKey)) {
        $adArgs['Credential'] = $CredentialMap[$credKey]
    }

    # Resolve the group's DN by SID. Get-ADGroupMember -Identity does not
    # reliably accept raw SID strings; Get-ADObject's -Properties does not
    # expose 'SID' (only ObjectSID), and asking for it crashes the call.
    $grpObj = $null
    try {
        $grpObj = Get-ADObject -Filter "objectSID -eq '$Sid'" -Properties DistinguishedName @adArgs |
            Select-Object -First 1
    } catch { }
    if (-not $grpObj) {
        try {
            $sidObj = New-Object System.Security.Principal.SecurityIdentifier($Sid)
            $grpObj = Get-ADObject -Identity $sidObj -Properties DistinguishedName @adArgs
        } catch { }
    }
    if (-not $grpObj) { return @() }

    try {
        $members = @(Get-ADGroupMember -Identity $grpObj.DistinguishedName @adArgs)
    } catch {
        return @()
    }

    $rows = foreach ($m in $members) {
        $memberSid = if ($m.SID) { [string]$m.SID.Value } else { $null }
        $memberSam = if ($m.PSObject.Properties['SamAccountName']) { [string]$m.SamAccountName } else { $null }

        $memberFqdn    = ConvertTo-DomainFqdnFromDn -Dn ([string]$m.DistinguishedName)
        $memberNetbios = Get-NetbiosFromFqdn -Fqdn $memberFqdn -Cache $DomainCache
        if (-not $memberNetbios) { $memberNetbios = $netbios }
        $memberAcc = if ($memberSam) { "$memberNetbios\$memberSam" } else { $memberSid }

        if ($m.ObjectClass -eq 'group' -and $memberSid -and -not $Visited.ContainsKey($memberSid)) {
            $groupRow = [pscustomobject]@{
                Sid = $memberSid; Account = $memberAcc; Type = 'Group'
                SamAccountName = $memberSam
                Name = $m.Name
                DistinguishedName = $m.DistinguishedName
                Domain = $memberFqdn
                Path = $newChain
                Skipped = $false
            }

            if ($SkipDefaultGroupExpansion -and (Test-IsDefaultGroupSid -Sid $memberSid)) {
                $groupRow.Skipped = $true
                $groupRow
                continue
            }
            $groupRow
            Expand-AdGroupMember -Sid $memberSid -Account $memberAcc -DomainCache $DomainCache `
                -SkipDefaultGroupExpansion:$SkipDefaultGroupExpansion -Visited $Visited -Chain $newChain `
                -CredentialMap $CredentialMap
            continue
        }

        $type = switch ($m.ObjectClass) {
            'user'                            { if ("$memberAcc".EndsWith('$')) { 'gMSA' } else { 'User' } }
            'msDS-GroupManagedServiceAccount' { 'gMSA' }
            'computer'                        { 'Computer' }
            'group'                           { 'Group' }
            default                           { 'User' }
        }

        [pscustomobject]@{
            Sid = $memberSid; Account = $memberAcc; Type = $type
            SamAccountName = $memberSam
            Name = $m.Name
            DistinguishedName = $m.DistinguishedName
            Domain = $memberFqdn
            Path = $newChain
            Skipped = $false
        }
    }

    @($rows)
}