Private/Collectors/Get-RemoteUserRightsAssignment.ps1

function Get-RemoteUserRightsAssignment {
    # On the target session: run `secedit /export /cfg <tmp>`, read the INF,
    # filter to the [Privilege Rights] block, transmit lines back. Jumphost
    # side: parse "SeXxx = *S-1-…,*S-1-…" and resolve each SID via the
    # existing Resolve-AdPrincipal (which uses $DomainCache).
    [CmdletBinding()]
    [OutputType([object[]])]
    param(
        [Parameter(Mandatory)][System.Management.Automation.Runspaces.PSSession]$Session,
        [Parameter(Mandatory)][hashtable]$DomainCache,
        [switch]$ExpandMembers,
        # FQDN(upper) → PSCredential map for cross-domain / cross-forest resolution.
        # Forwarded as -CredentialMap to Resolve-AdPrincipal.
        [hashtable]$DomainCredential = @{}
    )

    $tracked = @(
        'SeTcbPrivilege','SeBackupPrivilege','SeRestorePrivilege','SeDebugPrivilege',
        'SeServiceLogonRight','SeBatchLogonRight','SeInteractiveLogonRight',
        'SeRemoteInteractiveLogonRight','SeDenyInteractiveLogonRight',
        'SeDenyNetworkLogonRight','SeLoadDriverPrivilege','SeTakeOwnershipPrivilege',
        'SeAssignPrimaryTokenPrivilege','SeImpersonatePrivilege','SeShutdownPrivilege'
    )

    $lines = Invoke-Command -Session $Session -ArgumentList (,$tracked) -ScriptBlock {
        param([string[]]$Tracked)
        $tmp = [System.IO.Path]::GetTempFileName()
        try {
            $null = secedit /export /cfg $tmp /quiet 2>$null
            if (-not (Test-Path $tmp)) { return @() }
            $content = Get-Content $tmp -ErrorAction Stop
            $inPriv = $false
            $out = New-Object System.Collections.Generic.List[string]
            foreach ($ln in $content) {
                if ($ln -match '^\[Privilege Rights\]') { $inPriv = $true; continue }
                if ($inPriv -and $ln -match '^\[') { break }
                if ($inPriv -and $ln -match '^([A-Za-z]+)\s*=\s*(.+)$') {
                    if ($Tracked -contains $matches[1]) { $out.Add($ln) | Out-Null }
                }
            }
            ,$out.ToArray()
        } finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue }
    }

    $result = foreach ($ln in @($lines)) {
        if ($ln -notmatch '^([A-Za-z]+)\s*=\s*(.+)$') { continue }
        $priv = $matches[1]
        $rhs  = $matches[2].Trim()
        $sids = @($rhs -split ',' | ForEach-Object { ($_ -replace '^\*','').Trim() } | Where-Object { $_ })
        $principals = foreach ($sid in $sids) {
            try {
                $account = (New-Object System.Security.Principal.SecurityIdentifier($sid)).Translate([System.Security.Principal.NTAccount]).Value
            } catch { $account = $sid }
            Resolve-AdPrincipal -Sid $sid -Account $account -DomainCache $DomainCache -CredentialMap $DomainCredential
        }
        [pscustomobject]@{ Privilege = $priv; Principals = @($principals) }
    }
    $result = @($result)

    if (-not $ExpandMembers) { return $result }

    # Second pass: collect distinct BUILTIN\ groups referenced across all privileges,
    # enumerate their members on the target (with ADSI WinNT fallback), resolve on jumphost,
    # attach as .Members on each matching Principal entry.
    $builtinGroups = @(
        $result | ForEach-Object { $_.Principals } |
        Where-Object { $_.Type -eq 'WellKnown' -and $_.Account -match '^BUILTIN\\' } |
        Select-Object -ExpandProperty Account -Unique
    )

    if ($builtinGroups.Count -gt 0) {
        # Get the short group names (strip "BUILTIN\") for Get-LocalGroupMember.
        $groupNames = @($builtinGroups | ForEach-Object { ($_ -split '\\',2)[1] })

        $rawMembersLookup = Invoke-Command -Session $Session -ArgumentList (,$groupNames) -ScriptBlock {
            param([string[]]$GroupNames)

            function Get-RawGroupMembers([string]$GroupName) {
                try {
                    $rows = Get-LocalGroupMember -Group $GroupName -ErrorAction Stop |
                        ForEach-Object { [pscustomobject]@{ Sid = $_.SID.Value; Account = $_.Name } }
                    return @($rows)
                } catch { }
                try {
                    $grp = [ADSI]("WinNT://./" + $GroupName + ",group")
                    $members = $grp.psbase.Invoke('Members')
                    $rows = foreach ($m in $members) {
                        $path = try { $m.GetType().InvokeMember('AdsPath','GetProperty',$null,$m,$null) } catch { $null }
                        $name = try { $m.GetType().InvokeMember('Name','GetProperty',$null,$m,$null) }    catch { $null }
                        $account = if ($path -match '^WinNT://([^/]+)/([^/]+)$') { "$($matches[1])\$($matches[2])" } else { $name }
                        $sid = $null
                        if ($account) {
                            try { $sid = (New-Object System.Security.Principal.NTAccount($account)).Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { }
                        }
                        if (-not $sid) {
                            try {
                                $raw = $m.GetType().InvokeMember('objectSid','GetProperty',$null,$m,$null)
                                if ($raw) {
                                    $byteArr = [byte[]]@($raw | ForEach-Object { [byte]$_ })
                                    $sid = (New-Object System.Security.Principal.SecurityIdentifier($byteArr, 0)).Value
                                }
                            } catch { }
                        }
                        if ([string]::IsNullOrWhiteSpace($account) -and [string]::IsNullOrWhiteSpace($sid)) { continue }
                        [pscustomobject]@{ Sid = $sid; Account = $account }
                    }
                    return @($rows)
                } catch { }
                return @()
            }

            $lookup = @{}
            foreach ($gn in $GroupNames) {
                $lookup[$gn] = @(Get-RawGroupMembers $gn)
            }
            $lookup
        }

        # Build a lookup keyed by "BUILTIN\<name>" -> resolved members
        $resolvedMembersLookup = @{}
        foreach ($groupName in $groupNames) {
            $builtinKey = "BUILTIN\$groupName"
            try {
                $rawList = @($rawMembersLookup[$groupName])
                $resolved = @(foreach ($raw in $rawList) {
                    if ([string]::IsNullOrWhiteSpace($raw.Sid) -and [string]::IsNullOrWhiteSpace($raw.Account)) { continue }
                    try {
                        $member = Resolve-AdPrincipal -Sid $raw.Sid -Account $raw.Account -DomainCache $DomainCache -CredentialMap $DomainCredential
                        # Attach Path for "Inherited via" rendering (top-level group is the path root)
                        $member | Add-Member -NotePropertyName Path -NotePropertyValue @($builtinKey) -Force
                        $member
                    } catch { }
                })
                $resolvedMembersLookup[$builtinKey] = $resolved
            } catch { }
        }

        # Attach .Members to each Principal entry that is a BUILTIN\ group
        foreach ($entry in $result) {
            foreach ($principal in $entry.Principals) {
                if ($principal.Type -eq 'WellKnown' -and $principal.Account -match '^BUILTIN\\') {
                    $members = if ($resolvedMembersLookup.ContainsKey($principal.Account)) {
                        $resolvedMembersLookup[$principal.Account]
                    } else { @() }
                    $principal | Add-Member -NotePropertyName Members -NotePropertyValue $members -Force
                }
            }
        }
    }

    $result
}