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 ConvertTo-TierZeroGroup {
    <#
    .SYNOPSIS
    Formats a registry entry as a Tier Zero group output object
 
    .DESCRIPTION
    Returns a Deetz.TierZeroGroup object holding every Deetz.TierZeroReason recorded for the group,
    with the distinct sources and Tier Zero groups of those reasons.
 
    .PARAMETER Entry
    A registry entry created by Register-TierZeroAccount
 
    .PARAMETER Forest
    Forest details, as returned by Get-ForestInfo
 
    .EXAMPLE
    ConvertTo-TierZeroGroup -Entry $Entry -Forest $Forest
    #>

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

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

    $Object = $Entry.Object
    $DomainName = Get-DomainName -DistinguishedName $Object.DistinguishedName

    [pscustomobject]@{
        SamAccountName    = $Object.sAMAccountName
        Domain            = $DomainName
        NetBIOSDomain     = (@($Forest.Domains) + @($Forest.Trusts) | Where-Object { $PSItem.DNSRoot -eq $DomainName } | Select-Object -First 1).NetBIOSName
        AdminCount        = $Object.adminCount
        MemberCount       = @($Object.member | Where-Object { $PSItem }).Count
        Source            = [string[]]@($Entry.Reason.Source | Select-Object -Unique)
        TierZeroGroup     = [string[]]@($Entry.Reason.TierZeroGroup | Where-Object { $PSItem } | Select-Object -Unique)
        Reason            = [psobject[]]@($Entry.Reason)
        SID               = "$($Object.objectSid)"
        DistinguishedName = $Object.DistinguishedName
    } | Add-CustomType -Type 'Deetz.TierZeroGroup'

}

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.
    When Forest is supplied, foreign security principals found in a trusted domain are output as the trusted domain
    object, and trusted domain groups are expanded in that domain. Foreign security principals which cannot be found
    are output as they are.
 
    .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) {

        $NestedGroup = $null
        $Foreign = $null

        if ($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) {
                $Member = $Foreign
            }

        }

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

        if ($Member.ObjectClass -eq 'group') {
            $NestedGroup = $Member
        }

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

    }

}

function Find-TierZeroObject {
    <#
    .SYNOPSIS
    Finds the Tier Zero accounts and groups of a domain
 
    .DESCRIPTION
    Runs Tier Zero discovery by group membership, well known account, container ACL and object ACL,
    returning the forest details with separate registries of the accounts and groups found.
    Each registry is keyed on SID, with entries created by Register-TierZeroAccount.
    Default Tier Zero groups are not given ContainerACL or ObjectACL reasons, as they hold rights over most containers by default.
 
    .PARAMETER Domain
    DNS name of the domain to check
 
    .PARAMETER Credential
    Credential used for all directory queries
 
    .PARAMETER AdditionalGroup
    Names of further groups whose members are Tier Zero
 
    .PARAMETER SkipAclAnalysis
    Find accounts and groups by group membership and well known account only
 
    .EXAMPLE
    $Result = Find-TierZeroObject -Domain corp.local
    #>

    [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]@{}
    $GroupRegistry = [ordered]@{}
    $Groups = New-Object -TypeName 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)
    $DefaultGroups = 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)
                $Target = $GroupRegistry
            } else {
                $Target = $Registry
            }

            Register-TierZeroAccount -Registry $Target -Object $Item.Object -Source $Source -TierZeroGroup $TierZeroGroup -Path $Item.Path @Grant

        }

    }

    #Default Tier Zero groups
    $SeedGroups = @(
        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
            }

            $null = $DefaultGroups.Add($Group.DistinguishedName)
            Register-TierZeroAccount -Registry $GroupRegistry -Object $Group -Source DefaultGroup -TierZeroGroup $Group.'msDS-PrincipalName'
            $Group

        }
    )

    foreach ($Group in $SeedGroups) {
        & $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) {
            Register-TierZeroAccount -Registry $GroupRegistry -Object $Group -Source AdditionalGroup -TierZeroGroup $Group.'msDS-PrincipalName'
            & $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) {
                return
            }

            if ($Principal.ObjectClass -eq 'group') {

                if (-not $DefaultGroups.Contains($Principal.DistinguishedName)) {
                    Register-TierZeroAccount -Registry $GroupRegistry -Object $Principal -Source $Source @Grant
                }

                if (-not $Groups.Contains($Principal.DistinguishedName)) {
                    & $AddGroup $Principal $Source $Grant
                }

            } else {
                Register-TierZeroAccount -Registry $Registry -Object $Principal -Source $Source @Grant
            }

        }

        $AddSid = {
            param($SID, $Source, $Grant)

            $Right = $Grant.Right
            if ($Grant.ObjectType) {
                $Right = "$Right ($($Grant.ObjectType))"
            }

            if ($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 = $SID
                try {
                    $Principal = "$(([System.Security.Principal.SecurityIdentifier]$SID).Translate([System.Security.Principal.NTAccount]).Value) ($SID)"
                } catch {
                    Write-Verbose "Unable to translate $SID"
                }
                Write-Warning "Broad principal $Principal has $Right on $($Grant.Target)"
                return
            }

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

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

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

            & $AddPrincipal $Principal $Source $Grant

        }

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

            Write-Verbose "Checking ACL: $Target"

            $Properties = 'nTSecurityDescriptor', 'managedBy', 'userAccountControl', 'msDS-AllowedToActOnBehalfOfOtherIdentity', 'msDS-GroupMSAMembership'
            $TargetObject = Get-DirectoryObject -Identity $Target -Properties $Properties | 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 }
            }

            #Principals allowed to delegate to the object can impersonate any user to it
            if ($TargetObject.'msDS-AllowedToActOnBehalfOfOtherIdentity') {
                foreach ($SID in (Get-AllowedPrincipal -SecurityDescriptor $TargetObject.'msDS-AllowedToActOnBehalfOfOtherIdentity')) {
                    & $AddSid $SID $Source @{ Right = 'AllowedToAct'; Target = $Target }
                }
            }

            #Principals allowed to retrieve the password of a group managed service account control it
            if ($TargetObject.'msDS-GroupMSAMembership') {
                foreach ($SID in (Get-AllowedPrincipal -SecurityDescriptor $TargetObject.'msDS-GroupMSAMembership')) {
                    & $AddSid $SID $Source @{ Right = 'ReadGMSAPassword'; 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)) {
                & $AddSid $Ace.SID $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)

    }

    [pscustomobject]@{
        Forest  = $Forest
        Account = $Registry
        Group   = $GroupRegistry
    }

}

function Get-AllowedPrincipal {
    <#
    .SYNOPSIS
    Returns the SIDs granted access by a security descriptor attribute
 
    .DESCRIPTION
    Returns the distinct SIDs of the Allow entries of a security descriptor stored in an attribute,
    such as msDS-AllowedToActOnBehalfOfOtherIdentity or msDS-GroupMSAMembership.
    Accepts the attribute as returned by Get-ADObject, or in binary form.
 
    .PARAMETER SecurityDescriptor
    The attribute value
 
    .EXAMPLE
    Get-AllowedPrincipal -SecurityDescriptor $Computer.'msDS-AllowedToActOnBehalfOfOtherIdentity'
    #>

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

    $Descriptor = $SecurityDescriptor

    if ($Descriptor -is [byte[]]) {
        $Descriptor = New-Object -TypeName System.DirectoryServices.ActiveDirectorySecurity
        $Descriptor.SetSecurityDescriptorBinaryForm([byte[]]$SecurityDescriptor)
    }

    $Descriptor.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier]) |
        Where-Object { "$($PSItem.AccessControlType)" -eq 'Allow' } |
        ForEach-Object { "$($PSItem.IdentityReference)" } |
        Select-Object -Unique

}

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.
    Groups can also be DefaultGroup or AdditionalGroup.
 
    .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('DefaultGroup', 'AdditionalGroup', '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.PSObject.Copy() | 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
    )

    $Result = Find-TierZeroObject @PSBoundParameters

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

}

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

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

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

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

    $Result = Find-TierZeroObject @PSBoundParameters

    foreach ($Entry in $Result.Group.Values) {
        ConvertTo-TierZeroGroup -Entry $Entry -Forest $Result.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