Deetz.psm1

function Add-CustomType {
    <#
    .SYNOPSIS
    Add a custom type to an object
 
    .DESCRIPTION
    Adds specified custom type to PowerShell object.
 
    .PARAMETER Object
    The Object to add the type to
 
    .PARAMETER Type
    The name of the type to add.
 
    .EXAMPLE
    $Object | Add-CustomType -type 'Some.Custom.Type'
 
    Adds 'Some.Custom.Type' to $Object
 
    #>

    [CmdletBinding()]
    Param (
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true,
            ValueFromPipelineByPropertyName = $false
        )]
        [psobject]$Object,

        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $false,
            ValueFromPipelineByPropertyName = $false

        )]
        [string]$Type

    )

    Process {
        [Void]$Object.PSObject.TypeNames.Insert(0, $Type)

        $Object
    }
}

function ConvertTo-TierZeroAccount {
    <#
    .SYNOPSIS
    Formats a registry entry as a Tier Zero account output object
 
    .DESCRIPTION
    Returns a Deetz.TierZeroAccount object holding every Deetz.TierZeroReason recorded for the account,
    with the distinct sources and Tier Zero groups of those reasons.
    Foreign security principals are translated to DOMAIN\name where the SID resolves,
    and given the DNS and NetBIOS names of the trusted domain which issued the SID.
 
    .PARAMETER Entry
    A registry entry created by Register-TierZeroAccount
 
    .PARAMETER Forest
    Forest details, as returned by Get-ForestInfo
 
    .EXAMPLE
    ConvertTo-TierZeroAccount -Entry $Entry -Forest $Forest
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true
        )]
        [psobject]$Entry,

        [Parameter(
            Mandatory = $true
        )]
        [psobject]$Forest
    )

    $Object = $Entry.Object
    $SID = "$($Object.objectSid)"
    $SamAccountName = $Object.sAMAccountName
    $DomainName = $null
    $NetBIOSDomain = $null

    if ($Object.ObjectClass -eq 'foreignSecurityPrincipal') {

        $TrustedDomain = Get-SidDomain -SID $SID -Forest $Forest -TrustOnly
        $DomainName = $TrustedDomain.DNSRoot
        $NetBIOSDomain = $TrustedDomain.NetBIOSName

        try {
            $NTAccount = ([System.Security.Principal.SecurityIdentifier]$SID).Translate([System.Security.Principal.NTAccount]).Value
            $NetBIOSDomain, $SamAccountName = $NTAccount -split '\\', 2
        } catch {
            Write-Verbose "Unable to translate foreign security principal $SID"
        }

    } else {

        $DomainName = Get-DomainName -DistinguishedName $Object.DistinguishedName
        $NetBIOSDomain = (@($Forest.Domains) + @($Forest.Trusts) | Where-Object { $PSItem.DNSRoot -eq $DomainName } | Select-Object -First 1).NetBIOSName

    }

    $UserAccountControl = $Object.userAccountControl

    [pscustomobject]@{
        SamAccountName       = $SamAccountName
        Domain               = $DomainName
        NetBIOSDomain        = $NetBIOSDomain
        UserPrincipalName    = $Object.userPrincipalName
        ObjectClass          = $Object.ObjectClass
        Enabled              = $(if ($null -ne $UserAccountControl) { -not ($UserAccountControl -band 2) })
        AdminCount           = $Object.adminCount
        PasswordLastSet      = $(if ($Object.pwdLastSet -gt 0) { [datetime]::FromFileTime($Object.pwdLastSet) })
        LastLogonDate        = $(if ($Object.lastLogonTimestamp -gt 0) { [datetime]::FromFileTime($Object.lastLogonTimestamp) })
        ServicePrincipalName = [string[]]@($Object.servicePrincipalName | Where-Object { $PSItem })
        Source               = [string[]]@($Entry.Reason.Source | Select-Object -Unique)
        TierZeroGroup        = [string[]]@($Entry.Reason.TierZeroGroup | Where-Object { $PSItem } | Select-Object -Unique)
        Reason               = [psobject[]]@($Entry.Reason)
        SID                  = $SID
        DistinguishedName    = $Object.DistinguishedName
    } | Add-CustomType -Type 'Deetz.TierZeroAccount'

}

function Expand-GroupMember {
    <#
    .SYNOPSIS
    Recursively expands the membership of a group
 
    .DESCRIPTION
    Outputs every direct and nested member of a group, including groups, as an object holding the member
    and the chain of group names, from the outermost group down, through which it is a member.
    Members which hold the group as their primary group are included for domain groups.
    Groups already expanded in the same call are not expanded again, so circular nesting terminates.
    Foreign security principals for groups in a trusted domain are expanded in that domain when Forest is supplied.
 
    .PARAMETER Group
    The group directory object, with member and objectSid properties
 
    .PARAMETER Path
    The chain of group names leading to this group
 
    .PARAMETER Visited
    Distinguished names of groups already expanded
 
    .PARAMETER Forest
    Forest details, as returned by Get-ForestInfo, used to find the trusted domain of a foreign security principal
 
    .EXAMPLE
    Expand-GroupMember -Group $Group -Path 'Administrators'
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true
        )]
        [psobject]$Group,

        [Parameter(
            Mandatory = $true
        )]
        [string[]]$Path,

        [Parameter(
            Mandatory = $false
        )]
        [System.Collections.Generic.HashSet[string]]$Visited = (New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)),

        [Parameter(
            Mandatory = $false
        )]
        [psobject]$Forest
    )

    if (-not $Visited.Add($Group.DistinguishedName)) {
        return
    }

    $GroupSid = "$($Group.objectSid)"

    $Members = @(
        foreach ($MemberDN in $Group.member) {
            Get-DirectoryObject -Identity $MemberDN
        }

        if ($GroupSid -match '^S-1-5-21-.+-(\d+)$') {
            Get-DirectoryObject -LDAPFilter "(primaryGroupID=$($Matches[1]))" -Server (Get-DomainName -DistinguishedName $Group.DistinguishedName)
        }
    )

    foreach ($Member in $Members) {

        [pscustomobject]@{
            Object = $Member
            Path   = $Path
        }

        $NestedGroup = $null

        if ($Member.ObjectClass -eq 'group') {
            $NestedGroup = $Member
        } elseif ($Member.ObjectClass -eq 'foreignSecurityPrincipal' -and $Forest) {

            $MemberSid = "$($Member.objectSid)"
            $TrustedDomain = Get-SidDomain -SID $MemberSid -Forest $Forest -TrustOnly

            if ($TrustedDomain) {
                $Foreign = Get-DirectoryObject -LDAPFilter "(objectSid=$MemberSid)" -Server $TrustedDomain.DNSRoot | Select-Object -First 1
                if ($Foreign.ObjectClass -eq 'group') {
                    $NestedGroup = $Foreign
                }
            }

        }

        if ($NestedGroup) {
            Write-Verbose "`tChecking Nested Group: $($NestedGroup.'msDS-PrincipalName')"
            Expand-GroupMember -Group $NestedGroup -Path (@($Path) + $NestedGroup.'msDS-PrincipalName') -Visited $Visited -Forest $Forest
        }

    }

}

function Get-CredentialParameter {
    <#
    .SYNOPSIS
    Returns a splattable hashtable holding the session credential
 
    .DESCRIPTION
    Returns @{ Credential = <credential> } when a credential is held in the module session, otherwise an empty hashtable.
 
    .EXAMPLE
    $Credential = Get-CredentialParameter
    Get-ADDomain @Credential
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param()

    $Parameter = @{}

    if ($Script:DeetzSession.Credential) {
        $Parameter['Credential'] = $Script:DeetzSession.Credential
    }

    $Parameter

}

function Get-DirectoryObject {
    <#
    .SYNOPSIS
    Gets directory objects, caching results for the current discovery run
 
    .DESCRIPTION
    Wraps Get-ADObject. An Identity lookup targets the domain named by the DC= components of the distinguished name.
    Results are cached in the module session, keyed on server, query and properties.
    Lookups of an identity which does not exist return nothing and write a warning.
 
    .PARAMETER Identity
    The distinguished name of the object to get
 
    .PARAMETER LDAPFilter
    An LDAP filter to search with
 
    .PARAMETER Server
    The domain to search with the LDAP filter
 
    .PARAMETER Properties
    The properties to return
 
    .EXAMPLE
    Get-DirectoryObject -Identity 'CN=Domain Admins,CN=Users,DC=corp,DC=local'
 
    .EXAMPLE
    Get-DirectoryObject -LDAPFilter '(objectSid=S-1-5-32-544)' -Server corp.local
    #>

    [CmdletBinding(DefaultParameterSetName = 'Identity')]
    param(
        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'Identity'
        )]
        [string]$Identity,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'LDAPFilter'
        )]
        [string]$LDAPFilter,

        [Parameter(
            Mandatory = $true,
            ParameterSetName = 'LDAPFilter'
        )]
        [string]$Server,

        [Parameter(
            Mandatory = $false
        )]
        [string[]]$Properties = @(
            'msDS-PrincipalName',
            'sAMAccountName',
            'userPrincipalName',
            'objectSid',
            'userAccountControl',
            'pwdLastSet',
            'lastLogonTimestamp',
            'servicePrincipalName',
            'adminCount',
            'member'
        )
    )

    $Parameter = @{ Properties = $Properties } + (Get-CredentialParameter)

    switch ($PSCmdlet.ParameterSetName) {

        'Identity' {
            $Parameter['Identity'] = $Identity
            $Parameter['Server'] = Get-DomainName -DistinguishedName $Identity
        }

        'LDAPFilter' {
            $Parameter['LDAPFilter'] = $LDAPFilter
            $Parameter['Server'] = $Server
        }

    }

    $Key = '{0}|{1}{2}|{3}' -f $Parameter['Server'], $Identity, $LDAPFilter, ($Properties -join ',')

    if (-not $Script:DeetzSession.Cache.ContainsKey($Key)) {

        try {
            $Script:DeetzSession.Cache[$Key] = @(Get-ADObject @Parameter -ErrorAction Stop)
        } catch {
            Write-Warning "Unable to get directory object $Identity$LDAPFilter from $($Parameter['Server']): $($PSItem.Exception.Message)"
            $Script:DeetzSession.Cache[$Key] = @()
        }

    }

    $Script:DeetzSession.Cache[$Key]

}

function Get-DomainName {
    <#
    .SYNOPSIS
    Returns the DNS domain name of a distinguished name
 
    .DESCRIPTION
    Joins the DC= components of a distinguished name into a DNS domain name.
 
    .PARAMETER DistinguishedName
    The distinguished name of a directory object
 
    .EXAMPLE
    Get-DomainName -DistinguishedName 'CN=Administrator,CN=Users,DC=corp,DC=local'
 
    Returns corp.local
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true
        )]
        [string]$DistinguishedName
    )

    process {

        (($DistinguishedName -split '(?<!\\),' | Where-Object { $PSItem -match '^DC=' }) -replace '^DC=') -join '.'

    }

}

function Get-ForestInfo {
    <#
    .SYNOPSIS
    Gets details of the target domain and the domains of its forest
 
    .DESCRIPTION
    Returns the target domain, the forest root domain and every reachable domain of the forest,
    each with DNSRoot, NetBIOSName, DistinguishedName and DomainSID.
    Throws when the target domain or its forest cannot be queried.
    A forest domain which cannot be queried is reported with a warning and left out.
    Domains outside the forest which any forest domain trusts are returned as Trusts, with the same properties.
 
    .PARAMETER Domain
    The target domain. Defaults to the domain of the current computer.
 
    .EXAMPLE
    Get-ForestInfo -Domain corp.local
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $false
        )]
        [string]$Domain
    )

    $Credential = Get-CredentialParameter
    $DomainParameter = @{} + $Credential

    if ($Domain) {
        $DomainParameter['Server'] = $Domain
    }

    try {
        $TargetDomain = Get-ADDomain @DomainParameter -ErrorAction Stop
        $Forest = Get-ADForest -Identity $TargetDomain.Forest -Server $TargetDomain.DNSRoot @Credential -ErrorAction Stop
    } catch {
        throw "Unable to query domain $Domain`: $($PSItem.Exception.Message)"
    }

    $Domains = @(
        foreach ($ForestDomain in $Forest.Domains) {

            try {
                Get-ADDomain -Identity $ForestDomain -Server $ForestDomain @Credential -ErrorAction Stop |
                    Select-Object -Property DNSRoot, NetBIOSName, DistinguishedName, @{Name = 'DomainSID'; Expression = { "$($PSItem.DomainSID)" } }
            } catch {
                Write-Warning "Unable to query domain $ForestDomain`: $($PSItem.Exception.Message)"
            }

        }
    )

    $ForestRoot = $Domains | Where-Object { $PSItem.DNSRoot -eq $Forest.RootDomain }

    if (-not $ForestRoot) {
        Write-Warning "Forest root domain $($Forest.RootDomain) unavailable; forest level groups will not be checked"
    }

    $ForestSid = @($Domains | ForEach-Object { $PSItem.DomainSID })
    $Trusts = [ordered]@{}

    foreach ($ForestDomain in $Domains) {

        try {
            $TrustedDomain = Get-ADObject -LDAPFilter '(objectClass=trustedDomain)' -Server $ForestDomain.DNSRoot `
                -Properties trustPartner, flatName, securityIdentifier @Credential -ErrorAction Stop
        } catch {
            Write-Warning "Unable to query trusts of $($ForestDomain.DNSRoot): $($PSItem.Exception.Message)"
            continue
        }

        foreach ($Trust in $TrustedDomain) {

            if (-not $Trust.securityIdentifier) {
                continue
            }

            $TrustSid = if ($Trust.securityIdentifier -is [byte[]]) {
                "$(New-Object -TypeName System.Security.Principal.SecurityIdentifier -ArgumentList $Trust.securityIdentifier, 0)"
            } else {
                "$($Trust.securityIdentifier)"
            }

            if ($ForestSid -contains $TrustSid -or $Trusts.Contains($TrustSid)) {
                continue
            }

            $Trusts[$TrustSid] = [pscustomobject]@{
                DNSRoot           = $Trust.trustPartner
                NetBIOSName       = $Trust.flatName
                DistinguishedName = 'DC=' + ($Trust.trustPartner -replace '\.', ',DC=')
                DomainSID         = $TrustSid
            }

        }

    }

    [pscustomobject]@{
        Domain     = $Domains | Where-Object { $PSItem.DNSRoot -eq $TargetDomain.DNSRoot }
        ForestRoot = $ForestRoot
        Domains    = $Domains
        Trusts     = @($Trusts.Values)
    }

}

function Get-ParentDN {
    <#
    .SYNOPSIS
    Returns the distinguished name of the parent container of a directory object
 
    .DESCRIPTION
    Removes the first relative distinguished name from a distinguished name, honouring escaped commas.
 
    .PARAMETER DistinguishedName
    The distinguished name of a directory object
 
    .EXAMPLE
    Get-ParentDN -DistinguishedName 'CN=Smith\, John,OU=Admins,DC=corp,DC=local'
 
    Returns OU=Admins,DC=corp,DC=local
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true
        )]
        [string]$DistinguishedName
    )

    process {

        $DistinguishedName -replace '^.+?(?<!\\),'

    }

}

function Get-PrivilegedAce {
    <#
    .SYNOPSIS
    Returns the access control entries of a security descriptor which grant control of an object
 
    .DESCRIPTION
    Returns each Allow entry granting GenericAll, GenericWrite, WriteDacl or WriteOwner, and each Allow entry granting
    WriteProperty, Self or ExtendedRight either on all properties and rights or on one which allows takeover of the
    object or its domain (replication, password reset, group membership, key credentials, delegation, logon script).
    The owner of the object is returned with Rights Owner, as the owner can always change the permissions.
 
    .PARAMETER SecurityDescriptor
    The nTSecurityDescriptor of a directory object
 
    .PARAMETER ExcludeInherited
    Only return entries set on the object itself, not those inherited from its parent containers
 
    .EXAMPLE
    Get-PrivilegedAce -SecurityDescriptor $Object.nTSecurityDescriptor
 
    .EXAMPLE
    Get-PrivilegedAce -SecurityDescriptor $Object.nTSecurityDescriptor -ExcludeInherited
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true
        )]
        [psobject]$SecurityDescriptor,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$ExcludeInherited
    )

    $ObjectType = @{
        '00000000-0000-0000-0000-000000000000' = 'All'
        '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'
        '00299570-246d-11d0-a768-00aa006e0529' = 'User-Force-Change-Password'
        'bf9679c0-0de6-11d0-a285-00aa003049e2' = 'member'
        '5b47d60f-6090-40b2-9f37-2a4de88f3063' = 'msDS-KeyCredentialLink'
        'f3a64788-5306-11d1-a9c5-0000f80367c1' = 'servicePrincipalName'
        'f30e3bbe-9ff0-11d1-b603-0000f80367c1' = 'gPLink'
        '3f78c3e5-f79a-46bd-a0b8-9d18116ddc79' = 'msDS-AllowedToActOnBehalfOfOtherIdentity'
        'bf9679a8-0de6-11d0-a285-00aa003049e2' = 'scriptPath'
        'bf967a68-0de6-11d0-a285-00aa003049e2' = 'userAccountControl'
    }

    $Owner = $SecurityDescriptor.GetOwner([System.Security.Principal.SecurityIdentifier])

    if ($Owner) {
        [pscustomobject]@{
            SID        = "$Owner"
            Rights     = 'Owner'
            ObjectType = 'All'
        }
    }

    $AccessRules = $SecurityDescriptor.GetAccessRules($true, -not $ExcludeInherited, [System.Security.Principal.SecurityIdentifier])

    foreach ($Ace in $AccessRules) {

        if ("$($Ace.AccessControlType)" -ne 'Allow') {
            continue
        }

        $Rights = "$($Ace.ActiveDirectoryRights)"
        $AceObjectType = "$($Ace.ObjectType)"

        if ($Rights -match 'GenericAll|GenericWrite|WriteDacl|WriteOwner') {
            $Target = 'All'
        } elseif (($Rights -match 'WriteProperty|Self|ExtendedRight') -and $ObjectType.ContainsKey($AceObjectType)) {
            $Target = $ObjectType[$AceObjectType]
        } else {
            continue
        }

        [pscustomobject]@{
            SID        = "$($Ace.IdentityReference)"
            Rights     = $Rights
            ObjectType = $Target
        }

    }

}

function Get-SidDomain {
    <#
    .SYNOPSIS
    Returns the forest or trusted domain which issued a SID
 
    .DESCRIPTION
    Matches the SID against the domain SIDs of the forest domains and the
    trusted domains returned by Get-ForestInfo; with TrustOnly, only the trusted domains. Returns nothing for builtin, well-known or unknown SIDs.
 
    .PARAMETER SID
    The security identifier to look up
 
    .PARAMETER Forest
    Forest details, as returned by Get-ForestInfo
 
    .PARAMETER TrustOnly
    Only match trusted domains outside the forest
 
    .EXAMPLE
    Get-SidDomain -SID 'S-1-5-21-1-2-3-1105' -Forest $Forest
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true
        )]
        [string]$SID,

        [Parameter(
            Mandatory = $true
        )]
        [psobject]$Forest,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$TrustOnly
    )

    $Candidates = @(
        if (-not $TrustOnly) { $Forest.Domains }
        $Forest.Trusts
    )

    $Candidates | Where-Object { $PSItem.DomainSID -and $SID -like "$($PSItem.DomainSID)-*" } | Select-Object -First 1

}

function Get-TierZeroGroupSeed {
    <#
    .SYNOPSIS
    Returns the SIDs of the default Tier Zero groups
 
    .DESCRIPTION
    Returns the SID of each default Tier Zero group, with the domain to query it from.
    The groups are those rated Tier Zero by the SpecterOps Tier Zero Table which can be identified by SID.
    Builtin and domain groups come from the target domain, forest groups from the forest root domain.
 
    .PARAMETER Forest
    Forest details, as returned by Get-ForestInfo
 
    .EXAMPLE
    Get-ForestInfo | Get-TierZeroGroupSeed
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true
        )]
        [psobject]$Forest
    )

    process {

        $Domain = $Forest.Domain

        $BuiltinRid = @(
            544, # Administrators
            548, # Account Operators
            549, # Server Operators
            550, # Print Operators
            551, # Backup Operators
            557, # Incoming Forest Trust Builders
            559, # Performance Log Users
            562, # Distributed COM Users
            569  # Cryptographic Operators
        )

        $DomainRid = @(
            512, # Domain Admins
            516, # Domain Controllers
            517, # Cert Publishers
            526  # Key Admins
        )

        $ForestRid = @(
            518, # Schema Admins
            519, # Enterprise Admins
            527  # Enterprise Key Admins
        )

        foreach ($Rid in $BuiltinRid) {
            [pscustomobject]@{ SID = "S-1-5-32-$Rid"; Server = $Domain.DNSRoot }
        }

        foreach ($Rid in $DomainRid) {
            [pscustomobject]@{ SID = "$($Domain.DomainSID)-$Rid"; Server = $Domain.DNSRoot }
        }

        if ($Forest.ForestRoot) {
            foreach ($Rid in $ForestRid) {
                [pscustomobject]@{ SID = "$($Forest.ForestRoot.DomainSID)-$Rid"; Server = $Forest.ForestRoot.DNSRoot }
            }
        }

    }

}

function Register-TierZeroAccount {
    <#
    .SYNOPSIS
    Records a Tier Zero account and the reason it was found
 
    .DESCRIPTION
    Adds the account to the registry keyed on its SID (or distinguished name, where it has no SID),
    then adds a Deetz.TierZeroReason object describing how it was found to its entry, ignoring duplicate reasons.
 
    .PARAMETER Registry
    The dictionary of accounts found so far
 
    .PARAMETER Object
    The account directory object
 
    .PARAMETER Source
    How the account was found: GroupMembership, WellKnownAccount, ContainerACL or ObjectACL
 
    .PARAMETER TierZeroGroup
    The Tier Zero group, or group holding Tier Zero rights, through which the account was found
 
    .PARAMETER Path
    The chain of groups, from TierZeroGroup down to the group holding the account
 
    .PARAMETER Right
    The right held over the Target, such as GenericAll, Owner or managedBy
 
    .PARAMETER ObjectType
    The property, property set or extended right the Right applies to
 
    .PARAMETER Target
    Distinguished name of the Tier Zero container or object the Right is held over
 
    .EXAMPLE
    Register-TierZeroAccount -Registry $Registry -Object $User -Source GroupMembership -TierZeroGroup 'CORP\Domain Admins' -Path 'CORP\Domain Admins'
 
    .EXAMPLE
    Register-TierZeroAccount -Registry $Registry -Object $User -Source ContainerACL -Right GenericAll -ObjectType All -Target 'OU=Admins,DC=corp,DC=local'
    #>

    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true
        )]
        [System.Collections.IDictionary]$Registry,

        [Parameter(
            Mandatory = $true
        )]
        [psobject]$Object,

        [Parameter(
            Mandatory = $true
        )]
        [ValidateSet('GroupMembership', 'WellKnownAccount', 'ContainerACL', 'ObjectACL')]
        [string]$Source,

        [Parameter(
            Mandatory = $false
        )]
        [string]$TierZeroGroup,

        [Parameter(
            Mandatory = $false
        )]
        [string[]]$Path,

        [Parameter(
            Mandatory = $false
        )]
        [string]$Right,

        [Parameter(
            Mandatory = $false
        )]
        [string]$ObjectType,

        [Parameter(
            Mandatory = $false
        )]
        [string]$Target
    )

    $Key = "$($Object.objectSid)"

    if (-not $Key) {
        $Key = $Object.DistinguishedName
    }

    if (-not $Registry.Contains($Key)) {
        $Registry[$Key] = [pscustomobject]@{
            Object    = $Object
            Reason    = New-Object -TypeName 'System.Collections.Generic.List[psobject]'
            ReasonKey = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
        }
    }

    $Entry = $Registry[$Key]

    if ($Entry.ReasonKey.Add(($Source, $TierZeroGroup, ($Path -join ' > '), $Right, $ObjectType, $Target) -join '|')) {
        $Entry.Reason.Add(
            ([pscustomobject]@{
                Source        = $Source
                TierZeroGroup = $TierZeroGroup
                Path          = [string[]]@($Path | Where-Object { $PSItem })
                Right         = $Right
                ObjectType    = $ObjectType
                Target        = $Target
            } | Add-CustomType -Type 'Deetz.TierZeroReason')
        )
    }

}

# .ExternalHelp Deetz-help.xml
function Compare-TierZeroAccount {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true
        )]
        [psobject]$InputObject,

        [Parameter(
            Mandatory = $true
        )]
        [AllowEmptyCollection()]
        [psobject[]]$PAMAccount,

        [Parameter(
            Mandatory = $false
        )]
        [string]$UserNameProperty = 'userName',

        [Parameter(
            Mandatory = $false
        )]
        [string]$AddressProperty = 'address'
    )

    begin {

        $Keys = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)

        foreach ($Account in $PAMAccount) {

            $UserName = "$($Account.$UserNameProperty)"
            $Address = "$($Account.$AddressProperty)"

            if ($UserName -match '^(?<Domain>[^\\]+)\\(?<User>.+)$') {
                $null = $Keys.Add("$($Matches.User)|$($Matches.Domain)")
            } elseif ($UserName -match '^(?<User>[^@]+)@(?<Domain>.+)$') {
                $null = $Keys.Add("upn|$UserName")
                $null = $Keys.Add("$($Matches.User)|$($Matches.Domain)")
            } else {
                $null = $Keys.Add("$UserName|$Address")
            }

        }

    }

    process {

        $InPAM = $false

        foreach ($Key in @(
                "$($InputObject.SamAccountName)|$($InputObject.Domain)",
                "$($InputObject.SamAccountName)|$($InputObject.NetBIOSDomain)",
                "upn|$($InputObject.UserPrincipalName)"
            )) {

            if ($Key -notmatch '^\|' -and $Key -notmatch '\|$' -and $Keys.Contains($Key)) {
                $InPAM = $true
                break
            }

        }

        $InputObject | Add-Member -MemberType NoteProperty -Name InPAM -Value $InPAM -Force -PassThru

    }

}

# .ExternalHelp Deetz-help.xml
function ConvertTo-PAMAccount {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [AllowEmptyString()]
        [string]$SamAccountName,

        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [AllowEmptyString()]
        [string]$Domain,

        [Parameter(
            Mandatory = $true,
            ValueFromPipelineByPropertyName = $true
        )]
        [AllowEmptyString()]
        [string]$ObjectClass,

        [Parameter(
            Mandatory = $true
        )]
        [string]$SafeName,

        [Parameter(
            Mandatory = $true
        )]
        [string]$PlatformID
    )

    process {

        if ($ObjectClass -ne 'user' -or -not $Domain -or -not $SamAccountName) {
            Write-Verbose "Skipping $ObjectClass $Domain\$SamAccountName"
            return
        }

        [pscustomobject]@{
            userName   = $SamAccountName
            address    = $Domain
            platformID = $PlatformID
            SafeName   = $SafeName
        }

    }

}

# .ExternalHelp Deetz-help.xml
function ConvertTo-TierZeroReport {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $true,
            ValueFromPipeline = $true
        )]
        [psobject]$InputObject
    )

    process {

        $Account = [ordered]@{}

        foreach ($Property in $InputObject.PSObject.Properties) {
            if ($Property.Name -notin 'Reason', 'Source', 'TierZeroGroup') {
                $Account[$Property.Name] = $(if ($Property.Value -is [array]) { $Property.Value -join '; ' } else { $Property.Value })
            }
        }

        foreach ($Reason in $InputObject.Reason) {

            $Row = [ordered]@{} + $Account
            $Row['Source'] = $Reason.Source
            $Row['TierZeroGroup'] = $Reason.TierZeroGroup
            $Row['Path'] = @($Reason.Path) -join ' > '
            $Row['Right'] = $Reason.Right
            $Row['ObjectType'] = $Reason.ObjectType
            $Row['Target'] = $Reason.Target

            [pscustomobject]$Row

        }

    }

}

# .ExternalHelp Deetz-help.xml
function Get-TierZeroAccount {
    [CmdletBinding()]
    param(
        [Parameter(
            Mandatory = $false
        )]
        [string]$Domain,

        [Parameter(
            Mandatory = $false
        )]
        [pscredential]$Credential,

        [Parameter(
            Mandatory = $false
        )]
        [string[]]$AdditionalGroup,

        [Parameter(
            Mandatory = $false
        )]
        [switch]$SkipAclAnalysis
    )

    $Script:DeetzSession.Credential = $Credential
    $Script:DeetzSession.Cache = @{}

    $Forest = Get-ForestInfo -Domain $Domain
    $TargetDomain = $Forest.Domain.DNSRoot

    $Registry = [ordered]@{}
    $Groups = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)

    $AddGroup = {
        param($Group, $Source, $Grant)

        $TierZeroGroup = $Group.'msDS-PrincipalName'
        $null = $Groups.Add($Group.DistinguishedName)
        Write-Verbose "Checking Group: $TierZeroGroup"

        foreach ($Item in (Expand-GroupMember -Group $Group -Path $TierZeroGroup -Forest $Forest)) {

            if ($Item.Object.ObjectClass -eq 'group') {
                $null = $Groups.Add($Item.Object.DistinguishedName)
            } else {
                Register-TierZeroAccount -Registry $Registry -Object $Item.Object -Source $Source -TierZeroGroup $TierZeroGroup -Path $Item.Path @Grant
            }

        }

    }

    #Default Tier Zero groups
    foreach ($Seed in (Get-TierZeroGroupSeed -Forest $Forest)) {

        $Group = Get-DirectoryObject -LDAPFilter "(objectSid=$($Seed.SID))" -Server $Seed.Server | Select-Object -First 1

        if (-not $Group) {
            Write-Verbose "Group $($Seed.SID) not found in $($Seed.Server)"
            continue
        }

        & $AddGroup $Group GroupMembership @{}

    }

    #Groups named by the caller
    $GroupParameter = @{ Server = $TargetDomain } + (Get-CredentialParameter)

    foreach ($Name in $AdditionalGroup) {

        try {
            $ADGroup = Get-ADGroup -Identity $Name @GroupParameter -ErrorAction Stop
        } catch {
            Write-Warning "Unable to find group $Name`: $($PSItem.Exception.Message)"
            continue
        }

        $Group = Get-DirectoryObject -Identity $ADGroup.DistinguishedName | Select-Object -First 1

        if ($Group) {
            & $AddGroup $Group GroupMembership @{}
        }

    }

    #Built-in Administrator, krbtgt and read-only domain controller accounts
    foreach ($Filter in @(
            "(objectSid=$($Forest.Domain.DomainSID)-500)",
            '(&(objectClass=user)(sAMAccountName=krbtgt*))',
            '(&(objectClass=computer)(userAccountControl:1.2.840.113556.1.4.803:=67108864))'
        )) {

        foreach ($Account in (Get-DirectoryObject -LDAPFilter $Filter -Server $TargetDomain)) {
            Register-TierZeroAccount -Registry $Registry -Object $Account -Source WellKnownAccount
        }

    }

    if (-not $SkipAclAnalysis) {

        $AddPrincipal = {
            param($Principal, $Source, $Grant)

            if (-not $Principal -or $Groups.Contains($Principal.DistinguishedName)) {
                return
            }

            if ($Principal.ObjectClass -eq 'group') {
                & $AddGroup $Principal $Source $Grant
            } else {
                Register-TierZeroAccount -Registry $Registry -Object $Principal -Source $Source @Grant
            }

        }

        $CheckAcl = {
            param($Target, $Source)

            Write-Verbose "Checking ACL: $Target"

            $TargetObject = Get-DirectoryObject -Identity $Target -Properties nTSecurityDescriptor, managedBy, userAccountControl | Select-Object -First 1

            #The managedBy principal of a read-only domain controller is a local administrator of it
            if ($TargetObject.managedBy -and ($TargetObject.userAccountControl -band 67108864)) {
                $Manager = Get-DirectoryObject -Identity $TargetObject.managedBy | Select-Object -First 1
                & $AddPrincipal $Manager $Source @{ Right = 'managedBy'; Target = $Target }
            }

            if (-not $TargetObject.nTSecurityDescriptor) {
                if ($TargetObject) {
                    Write-Warning "Unable to read permissions of $Target"
                }
                return
            }

            $AceParameter = @{
                SecurityDescriptor = $TargetObject.nTSecurityDescriptor
                ExcludeInherited   = $Source -eq 'ObjectACL'
            }

            foreach ($Ace in (Get-PrivilegedAce @AceParameter)) {

                if ($Ace.SID -match '^(S-1-1-0|S-1-5-7|S-1-5-11|S-1-5-21-.+-(513|515)|S-1-5-32-(545|546|554))$') {
                    $Principal = $Ace.SID
                    try {
                        $Principal = "$(([System.Security.Principal.SecurityIdentifier]$Ace.SID).Translate([System.Security.Principal.NTAccount]).Value) ($($Ace.SID))"
                    } catch {
                        Write-Verbose "Unable to translate $($Ace.SID)"
                    }
                    Write-Warning "Broad principal $Principal has $($Ace.Rights) ($($Ace.ObjectType)) on $Target"
                    continue
                }

                if ($Ace.SID -match '^S-1-5-32-') {
                    $Server = Get-DomainName -DistinguishedName $Target
                } elseif ($Ace.SID -match '^S-1-5-21-') {
                    $Server = (Get-SidDomain -SID $Ace.SID -Forest $Forest).DNSRoot
                } else {
                    continue
                }

                if (-not $Server) {
                    Write-Verbose "No reachable domain for $($Ace.SID) on $Target"
                    continue
                }

                $Principal = Get-DirectoryObject -LDAPFilter "(objectSid=$($Ace.SID))" -Server $Server | Select-Object -First 1

                & $AddPrincipal $Principal $Source @{ Right = $Ace.Rights; ObjectType = $Ace.ObjectType; Target = $Target }

            }

        }

        #Principals with control of a Tier Zero object are Tier Zero in turn, so repeat until nothing new is found
        $Checked = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)

        do {

            $Accounts = @(
                $Registry.Values | Where-Object { $PSItem.Object.ObjectClass -ne 'foreignSecurityPrincipal' } |
                    ForEach-Object { $PSItem.Object.DistinguishedName }
            )

            $Containers = @(
                @(
                    $Forest.Domain.DistinguishedName,
                    "CN=Users,$($Forest.Domain.DistinguishedName)",
                    "CN=AdminSDHolder,CN=System,$($Forest.Domain.DistinguishedName)",
                    "OU=Domain Controllers,$($Forest.Domain.DistinguishedName)"
                    @($Groups) | Get-ParentDN
                    $Registry.Values | ForEach-Object { $PSItem.Object.DistinguishedName } | Get-ParentDN
                ) | Sort-Object -Unique | Where-Object { $Checked.Add($PSItem) }
            )

            $Objects = @(
                @(@($Groups) + $Accounts) | Sort-Object -Unique | Where-Object { $PSItem -and $Checked.Add($PSItem) }
            )

            foreach ($Container in $Containers) {
                & $CheckAcl $Container ContainerACL
            }

            foreach ($Object in $Objects) {
                & $CheckAcl $Object ObjectACL
            }

        } while ($Containers -or $Objects)

    }

    foreach ($Entry in $Registry.Values) {
        ConvertTo-TierZeroAccount -Entry $Entry -Forest $Forest
    }

}

# Script scope session object holding directory query state for the current discovery run
$Script:DeetzSession = [ordered]@{
    Credential = $null
    Cache      = @{}
} | Add-CustomType -Type Deetz.Session

New-Variable -Name DeetzSession -Value $Script:DeetzSession -Scope Script -Force