Private/Get-IndividualPrincipals.ps1

function Get-IndividualPrincipals {
    <#
        .SYNOPSIS
        Extracts DirectoryEntry objects for all individual principals identified in ESC4/ESC5 issues.
 
        .DESCRIPTION
        This function takes ESCalatorIssue objects from Find-ESC4Issue and Find-ESC5Issue and returns DirectoryEntry
        objects for each unique individual principal (users, computers) that has been identified
        with permissions on AD CS objects. For well-known security principals that don't exist in Active Directory
        (like SYSTEM), it creates mock DirectoryEntry objects with appropriate properties. Supports automatic array flattening for multiple input arrays.
 
        .PARAMETER Issues
        Array of ESCalatorIssue objects from Find-ESC4Issue, Find-ESC5Issue, or other vulnerability scanning functions.
        Supports multiple arrays that will be automatically flattened.
 
        .PARAMETER IncludeGroups
        Switch to include group principals in the output. By default, only individual users and computers are included.
 
        .INPUTS
        ESCalatorIssue[]
        ESCalatorIssue objects with IdentityReference and IdentityReferenceSID properties.
        Supports multiple arrays that will be automatically flattened.
 
        .OUTPUTS
        System.DirectoryServices.DirectoryEntry[]
        DirectoryEntry objects for each unique individual principal. For well-known security principals
        that don't exist in Active Directory, returns mock DirectoryEntry objects with TypeName 'MockDirectoryEntry'.
 
        .EXAMPLE
        $AdcsObjects = Get-AdcsObjects
        $AllIssues = @(Find-ESC4Issue -AdcsObjects $AdcsObjects; Find-ESC5Issue -AdcsObjects $AdcsObjects)
        $ObjectsWithIssues = Add-Issue -AdcsObjects $AdcsObjects -Issues $AllIssues
        $AllIndividualMemberIssues = $ObjectsWithIssues | ForEach-Object { $_.IndividualMemberIssues }
        $IndividualPrincipals = Get-IndividualPrincipals -Issues $AllIndividualMemberIssues
        $IndividualPrincipals | Select-Object Name, samAccountName, objectClass
 
        .EXAMPLE
        # Include groups in the output
        $AllPrincipals = Get-IndividualPrincipals -Issues $AllIndividualMemberIssues -IncludeGroups
 
        .LINK
        https://posts.specterops.io/certified-pre-owned-d95910965cd2
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [object[]]$Issues,
        
        [Parameter()]
        [switch]$IncludeGroups
    )


    begin {
        Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd hh:mm:ss')] Starting $($MyInvocation.MyCommand) on $env:COMPUTERNAME..."
        
        # Load ESCalatorIssue class if not already loaded
        if (-not ([System.Management.Automation.PSTypeName]'ESCalatorIssue').Type) {
            $escalatorIssuePath = Join-Path $PSScriptRoot "ESCalatorIssue.ps1"
            if (Test-Path $escalatorIssuePath) {
                . $escalatorIssuePath
            } else {
                throw "ESCalatorIssue class not found. Please ensure ESCalatorIssue.ps1 is available."
            }
        }
        
        $principalSIDs = @{}
        $AllIssues = @()
        $NonESCalatorIssues = @()
    }

    process {
        Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd hh:mm:ss')] Processing $($MyInvocation.MyCommand) on $env:COMPUTERNAME..."
        
        # Flatten any nested arrays and validate all items are ESCalatorIssue objects
        $Issues | ForEach-Object { 
            if ($_.PSObject.TypeNames[0] -eq 'ESCalatorIssue') { 
                $AllIssues += $_ 
            } elseif ($_ -is [Array]) {
                # Recursively flatten nested arrays
                $_ | ForEach-Object { 
                    if ($_.PSObject.TypeNames[0] -eq 'ESCalatorIssue') { 
                        $AllIssues += $_ 
                    } else {
                        $NonESCalatorIssues += $_
                    }
                }
            } else {
                $NonESCalatorIssues += $_
            }
        }
        
        # Warn about non-ESCalatorIssue objects but continue processing
        if ($NonESCalatorIssues.Count -gt 0) {
            Write-Warning "Found $($NonESCalatorIssues.Count) non-ESCalatorIssue objects that will be ignored. Expected ESCalatorIssue objects."
        }
        
        foreach ($issue in $AllIssues) {
            Write-Verbose "Processing issue for principal: $($issue.IdentityReference)"
            
            try {
                # Skip groups if not requested
                if (-not $IncludeGroups -and $issue.MemberType -eq 'GroupPrincipal') {
                    Write-Verbose "Skipping group principal: $($issue.IdentityReference)"
                    continue
                }
                
                # Collect unique SIDs
                if ($issue.IdentityReferenceSID -and -not $principalSIDs.ContainsKey($issue.IdentityReferenceSID)) {
                    $principalSIDs[$issue.IdentityReferenceSID] = $issue.IdentityReference
                }
                
            } catch {
                Write-Warning "Failed to process issue for principal $($issue.IdentityReference): $_"
            }
        }
    }

    end {
        Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd hh:mm:ss')] Processing final results for $($MyInvocation.MyCommand) on $env:COMPUTERNAME..."
        Write-Verbose "Found $($principalSIDs.Count) unique principals"
        
        $directoryEntries = @()
        
        foreach ($sid in $principalSIDs.Keys) {
            try {
                Write-Verbose "Creating DirectoryEntry for SID: $sid ($($principalSIDs[$sid]))"
                
                # Create DirectoryEntry using the SID
                $searcher = New-Object System.DirectoryServices.DirectorySearcher
                $searcher.Filter = "(objectSid=$sid)"
                $searcher.PropertiesToLoad.AddRange(@('distinguishedName', 'samAccountName', 'name', 'objectClass', 'objectSid'))
                
                $result = $searcher.FindOne()
                if ($result) {
                    $directoryEntry = $result.GetDirectoryEntry()
                    $directoryEntries += $directoryEntry
                    Write-Verbose "Successfully created DirectoryEntry for: $($directoryEntry.Name)"
                } else {
                    Write-Verbose "Could not find AD object for SID: $sid ($($principalSIDs[$sid])). Trying well-known security principal paths."
                    
                    # Dynamically enumerate well-known security principals from AD
                    try {
                        # Get the root DSE to find the configuration naming context
                        $rootDSE = New-Object System.DirectoryServices.DirectoryEntry("LDAP://RootDSE")
                        $configNC = $rootDSE.Properties['configurationNamingContext'][0]
                        
                        # Search the WellKnown Security Principals container
                        $wellKnownPath = "LDAP://CN=WellKnown Security Principals,$configNC"
                        Write-Verbose "Searching well-known security principals in: $wellKnownPath"
                        
                        $wellKnownSearcher = New-Object System.DirectoryServices.DirectorySearcher
                        $wellKnownSearcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry($wellKnownPath)
                        $wellKnownSearcher.Filter = "(objectClass=foreignSecurityPrincipal)"
                        $wellKnownSearcher.PropertiesToLoad.AddRange(@('distinguishedName', 'objectSid', 'name'))
                        
                        $wellKnownResults = $wellKnownSearcher.FindAll()
                        
                        $foundWellKnownPrincipal = $false
                        foreach ($wellKnownResult in $wellKnownResults) {
                            try {
                                $wellKnownSidBytes = $wellKnownResult.Properties['objectsid'][0]
                                $wellKnownSid = New-Object System.Security.Principal.SecurityIdentifier($wellKnownSidBytes, 0)
                                
                                if ($wellKnownSid.Value -eq $sid) {
                                    $wellKnownDN = $wellKnownResult.Properties['distinguishedname'][0]
                                    Write-Verbose "Found matching well-known principal: $wellKnownDN"
                                    
                                    $directoryEntry = New-Object System.DirectoryServices.DirectoryEntry("LDAP://$wellKnownDN")
                                    
                                    # Verify this is the correct object by checking if we can access its properties
                                    $null = $directoryEntry.Properties.Count
                                    
                                    $directoryEntries += $directoryEntry
                                    Write-Verbose "Successfully created DirectoryEntry for well-known principal: $($principalSIDs[$sid])"
                                    $foundWellKnownPrincipal = $true
                                    break
                                }
                            } catch {
                                Write-Verbose "Error processing well-known principal result: $_"
                                continue
                            }
                        }
                        
                        if (-not $foundWellKnownPrincipal) {
                            throw "No matching well-known principal found for SID $sid"
                        }
                        
                    } catch {
                        Write-Verbose "Failed to find well-known principal for $sid ($($principalSIDs[$sid])): $_"
                        Write-Verbose "Creating mock DirectoryEntry instead."
                        
                        # Fallback to creating a mock object
                        $mockEntry = New-Object PSObject
                        
                        # Add properties that mimic a real DirectoryEntry
                        $mockEntry | Add-Member -MemberType NoteProperty -Name "Name" -Value $principalSIDs[$sid]
                        $mockEntry | Add-Member -MemberType NoteProperty -Name "samAccountName" -Value $principalSIDs[$sid]
                        $mockEntry | Add-Member -MemberType NoteProperty -Name "distinguishedName" -Value "CN=$($principalSIDs[$sid]),CN=WellKnownSecurityPrincipals,CN=Configuration"
                        $mockEntry | Add-Member -MemberType NoteProperty -Name "objectClass" -Value @('top', 'foreignSecurityPrincipal')
                        $mockEntry | Add-Member -MemberType NoteProperty -Name "objectSid" -Value $sid
                        $mockEntry | Add-Member -MemberType NoteProperty -Name "Path" -Value "LDAP://CN=$($principalSIDs[$sid]),CN=WellKnownSecurityPrincipals,CN=Configuration"
                        $mockEntry | Add-Member -MemberType NoteProperty -Name "IsMock" -Value $true -Force
                        
                        # Create Properties collection that mimics real DirectoryEntry.Properties
                        $propertiesCollection = @{
                            'name' = @($principalSIDs[$sid])
                            'samaccountname' = @($principalSIDs[$sid])
                            'distinguishedname' = @("CN=$($principalSIDs[$sid]),CN=WellKnownSecurityPrincipals,CN=Configuration")
                            'objectclass' = @('top', 'foreignSecurityPrincipal')
                            'objectsid' = @($sid)
                        }
                        $mockEntry | Add-Member -MemberType NoteProperty -Name "Properties" -Value $propertiesCollection
                        
                        # Set the type name to make it appear as close to a DirectoryEntry as possible
                        $mockEntry.PSObject.TypeNames.Clear()
                        $mockEntry.PSObject.TypeNames.Add('System.DirectoryServices.DirectoryEntry')
                        $mockEntry.PSObject.TypeNames.Add('MockDirectoryEntry')
                        
                        $directoryEntries += $mockEntry
                        Write-Verbose "Successfully created mock DirectoryEntry for: $($principalSIDs[$sid])"
                    }
                }
                
            } catch {
                Write-Warning "Failed to create DirectoryEntry for SID $sid ($($principalSIDs[$sid])): $_"
            }
        }
        
        Write-Verbose "Returning $($directoryEntries.Count) DirectoryEntry objects"
        Write-Output $directoryEntries
        
        Write-Verbose "[$(Get-Date -Format 'yyyy-MM-dd hh:mm:ss')] Finishing $($MyInvocation.MyCommand) on $env:COMPUTERNAME..."
    }
}