Private/AD/Core/Get-ADPasswordPolicies.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
function Get-ADPasswordPolicies {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$Connection,

        [switch]$Quiet
    )

    $result = @{
        DefaultPolicy             = $null
        FineGrainedPolicies       = @()
        UsersPasswordNeverExpires = @()
        LAPSDeployed              = $false
        LAPSType                  = 'None'
        LAPSComputers             = 0
        TotalComputers            = 0
        BitLockerKeys             = 0
    }

    # ── Default Domain Password Policy ────────────────────────────────────────
    Write-Verbose 'Reading default domain password policy from domain root object...'
    try {
        $domainRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.DomainDN
        # @(...) forces array context — Base-scope queries return a single hashtable
        # that PowerShell would otherwise unwrap, breaking the .Count / [0] access below.
        $domainProps = @(Invoke-LdapQuery -SearchRoot $domainRoot `
            -Filter '(objectClass=domainDNS)' `
            -Properties @(
                'minPwdLength', 'pwdProperties', 'pwdHistoryLength',
                'maxPwdAge', 'minPwdAge', 'lockoutThreshold',
                'lockoutDuration', 'lockOutObservationWindow'
            ) `
            -Scope Base)

        if ($domainProps.Count -gt 0) {
            $dp = $domainProps[0]
            $pwdProps = [int]($dp['pwdproperties'] ?? 0)

            $result.DefaultPolicy = @{
                MinPasswordLength      = [int]($dp['minpwdlength'] ?? 0)
                PasswordComplexity     = ($pwdProps -band 1) -ne 0
                PasswordHistoryCount   = [int]($dp['pwdhistorylength'] ?? 0)
                MaxPasswordAge         = $dp['maxpwdage'] ?? [timespan]::Zero
                MinPasswordAge         = $dp['minpwdage'] ?? [timespan]::Zero
                LockoutThreshold       = [int]($dp['lockoutthreshold'] ?? 0)
                LockoutDuration        = $dp['lockoutduration'] ?? [timespan]::Zero
                LockoutObservationWindow = $dp['lockoutobservationwindow'] ?? [timespan]::Zero
                ReversibleEncryption   = ($pwdProps -band 16) -ne 0
            }

            if (-not $Quiet) {
                Write-Verbose ("Default policy: MinLen={0}, Complexity={1}, History={2}, MaxAge={3:g}, LockoutThreshold={4}" -f `
                    $result.DefaultPolicy.MinPasswordLength,
                    $result.DefaultPolicy.PasswordComplexity,
                    $result.DefaultPolicy.PasswordHistoryCount,
                    $result.DefaultPolicy.MaxPasswordAge,
                    $result.DefaultPolicy.LockoutThreshold)
            }
        } else {
            Write-Warning 'Could not read default domain password policy.'
        }
    } catch {
        Write-Warning "Failed to read default domain password policy: $_"
    }

    # ── Fine-Grained Password Policies (FGPPs) ───────────────────────────────
    Write-Verbose 'Querying fine-grained password policies (msDS-PasswordSettings)...'
    try {
        $psoContainerDN = "CN=Password Settings Container,CN=System,$($Connection.DomainDN)"
        $psoRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $psoContainerDN

        $fgpps = Invoke-LdapQuery -SearchRoot $psoRoot `
            -Filter '(objectClass=msDS-PasswordSettings)' `
            -Properties @(
                'name', 'distinguishedName', 'msDS-PasswordSettingsPrecedence',
                'msDS-MinimumPasswordLength', 'msDS-PasswordComplexityEnabled',
                'msDS-PasswordHistoryLength', 'msDS-MaximumPasswordAge',
                'msDS-MinimumPasswordAge', 'msDS-LockoutThreshold',
                'msDS-LockoutDuration', 'msDS-LockoutObservationWindow',
                'msDS-PasswordReversibleEncryptionEnabled', 'msDS-PSOAppliesTo'
            )

        $fgppList = [System.Collections.Generic.List[hashtable]]::new()

        foreach ($pso in $fgpps) {
            $appliesTo = $pso['msds-psoappliedto'] ?? $pso['msds-psoappliesTo'] ?? $pso['msds-psoapplies'] ?? @()
            # Normalize AppliesTo to an array
            if ($appliesTo -isnot [array]) { $appliesTo = @($appliesTo) }

            $fgpp = @{
                Name                   = $pso['name'] ?? ''
                DN                     = $pso['distinguishedname'] ?? ''
                Precedence             = [int]($pso['msds-passwordsettingsprecedence'] ?? 0)
                MinPasswordLength      = [int]($pso['msds-minimumpasswordlength'] ?? 0)
                PasswordComplexity     = [bool]($pso['msds-passwordcomplexityenabled'] ?? $false)
                PasswordHistoryCount   = [int]($pso['msds-passwordhistorylength'] ?? 0)
                MaxPasswordAge         = $pso['msds-maximumpasswordage'] ?? [timespan]::Zero
                MinPasswordAge         = $pso['msds-minimumpasswordage'] ?? [timespan]::Zero
                LockoutThreshold       = [int]($pso['msds-lockoutthreshold'] ?? 0)
                LockoutDuration        = $pso['msds-lockoutduration'] ?? [timespan]::Zero
                LockoutObservationWindow = $pso['msds-lockoutobservationwindow'] ?? [timespan]::Zero
                ReversibleEncryption   = [bool]($pso['msds-passwordreversibleencryptionenabled'] ?? $false)
                AppliesTo              = @($appliesTo)
            }
            $fgppList.Add($fgpp)
        }

        $result.FineGrainedPolicies = @($fgppList)
        Write-Verbose "Found $($fgppList.Count) fine-grained password policy(ies)."
    } catch {
        Write-Verbose "Fine-grained password policy query failed (may not exist or insufficient permissions): $_"
    }

    # ── Users with Password Never Expires ─────────────────────────────────────
    Write-Verbose 'Querying users with DONT_EXPIRE_PASSWORD flag...'
    try {
        $searchRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.DomainDN
        $neverExpireUsers = Invoke-LdapQuery -SearchRoot $searchRoot `
            -Filter '(&(objectCategory=person)(objectClass=user)(userAccountControl:1.2.840.113556.1.4.803:=65536))' `
            -Properties @('samaccountname', 'distinguishedname', 'useraccountcontrol', 'pwdlastset', 'admincount')

        $neverExpireList = [System.Collections.Generic.List[hashtable]]::new()
        foreach ($user in $neverExpireUsers) {
            $neverExpireList.Add(@{
                SamAccountName     = $user['samaccountname'] ?? ''
                DN                 = $user['distinguishedname'] ?? ''
                UserAccountControl = [int]($user['useraccountcontrol'] ?? 0)
                PwdLastSet         = $user['pwdlastset']
                AdminCount         = [int]($user['admincount'] ?? 0)
            })
        }

        $result.UsersPasswordNeverExpires = @($neverExpireList)
        Write-Verbose "Found $($neverExpireList.Count) user(s) with password-never-expires."
    } catch {
        Write-Warning "Failed to query users with password-never-expires: $_"
    }

    # ── LAPS Deployment Check ─────────────────────────────────────────────────
    Write-Verbose 'Checking LAPS deployment in schema...'
    try {
        $schemaRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.SchemaDN

        $hasLegacyLAPS = $false
        $hasWindowsLAPS = $false

        # Check for legacy LAPS (ms-Mcs-AdmPwd)
        $legacyCheck = @(Invoke-LdapQuery -SearchRoot $schemaRoot `
            -Filter '(&(objectClass=attributeSchema)(lDAPDisplayName=ms-Mcs-AdmPwd))' `
            -Properties @('lDAPDisplayName') `
            -SizeLimit 1)
        if ($legacyCheck.Count -gt 0) {
            $hasLegacyLAPS = $true
            Write-Verbose 'Legacy LAPS schema attribute (ms-Mcs-AdmPwd) found.'
        }

        # Check for Windows LAPS (msLAPS-Password)
        $windowsCheck = @(Invoke-LdapQuery -SearchRoot $schemaRoot `
            -Filter '(&(objectClass=attributeSchema)(lDAPDisplayName=msLAPS-Password))' `
            -Properties @('lDAPDisplayName') `
            -SizeLimit 1)
        if ($windowsCheck.Count -gt 0) {
            $hasWindowsLAPS = $true
            Write-Verbose 'Windows LAPS schema attribute (msLAPS-Password) found.'
        }

        $result.LAPSDeployed = $hasLegacyLAPS -or $hasWindowsLAPS
        $result.LAPSType = if ($hasLegacyLAPS -and $hasWindowsLAPS) { 'Both' }
                           elseif ($hasLegacyLAPS) { 'Legacy' }
                           elseif ($hasWindowsLAPS) { 'Windows' }
                           else { 'None' }

        Write-Verbose "LAPS deployment: $($result.LAPSType)"
    } catch {
        Write-Warning "Failed to check LAPS schema attributes: $_"
    }

    # ── LAPS Computer Coverage & Total Computers ──────────────────────────────
    Write-Verbose 'Counting computers with LAPS passwords and total computer objects...'
    try {
        $compRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.DomainDN

        # Count all computers excluding domain controllers (SERVER_TRUST_ACCOUNT = 0x2000 = 8192)
        $allComputers = Invoke-LdapQuery -SearchRoot $compRoot `
            -Filter '(&(objectCategory=computer)(!(userAccountControl:1.2.840.113556.1.4.803:=8192)))' `
            -Properties @('distinguishedname')
        $result.TotalComputers = $allComputers.Count

        # Count computers with LAPS passwords based on what is deployed
        $lapsCount = 0
        if ($hasLegacyLAPS) {
            $legacyLaps = Invoke-LdapQuery -SearchRoot $compRoot `
                -Filter '(&(objectCategory=computer)(ms-Mcs-AdmPwd=*))' `
                -Properties @('distinguishedname')
            $lapsCount += $legacyLaps.Count
        }
        if ($hasWindowsLAPS) {
            $windowsLaps = Invoke-LdapQuery -SearchRoot $compRoot `
                -Filter '(&(objectCategory=computer)(msLAPS-Password=*))' `
                -Properties @('distinguishedname')
            # Avoid double-counting if a computer has both
            if ($hasLegacyLAPS) {
                $legacyDNs = [System.Collections.Generic.HashSet[string]]::new(
                    [StringComparer]::OrdinalIgnoreCase
                )
                foreach ($c in $legacyLaps) { [void]$legacyDNs.Add($c['distinguishedname']) }
                foreach ($c in $windowsLaps) {
                    if (-not $legacyDNs.Contains($c['distinguishedname'])) {
                        $lapsCount++
                    }
                }
            } else {
                $lapsCount += $windowsLaps.Count
            }
        }
        $result.LAPSComputers = $lapsCount

        Write-Verbose "LAPS coverage: $lapsCount of $($result.TotalComputers) computers."
    } catch {
        Write-Warning "Failed to count LAPS computers: $_"
    }

    # ── BitLocker Recovery Keys in AD ─────────────────────────────────────────
    Write-Verbose 'Counting BitLocker recovery keys stored in AD...'
    try {
        $blRoot = New-LdapSearchRoot -Connection $Connection -SearchBase $Connection.DomainDN
        $bitlockerKeys = Invoke-LdapQuery -SearchRoot $blRoot `
            -Filter '(objectClass=msFVE-RecoveryInformation)' `
            -Properties @('distinguishedname')
        $result.BitLockerKeys = $bitlockerKeys.Count
        Write-Verbose "Found $($bitlockerKeys.Count) BitLocker recovery key(s) in AD."
    } catch {
        Write-Warning "Failed to count BitLocker recovery keys: $_"
    }

    return $result
}