Private/AD/Checks/Invoke-ADKerberosChecks.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 Invoke-ADKerberosChecks {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [hashtable]$AuditData
    )

    $checkDefs = Get-AuditCategoryDefinitions -Category 'ADKerberosChecks'
    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($check in $checkDefs.checks) {
        $funcName = "Test-Recon$($check.id -replace '-', '')"
        if (Get-Command $funcName -ErrorAction SilentlyContinue) {
            try {
                $finding = & $funcName -AuditData $AuditData -CheckDefinition $check
                if ($finding) { $findings.Add($finding) }
            } catch {
                $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'ERROR' `
                    -CurrentValue "Check failed: $_"))
            }
        } else {
            $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'SKIP' `
                -CurrentValue 'Check not yet implemented'))
        }
    }

    return @($findings)
}

# ── ADKERB-001: Kerberoastable Accounts ────────────────────────────────────
function Test-ReconADKERB001 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $allAccounts = @($kerb.KerberoastableAccounts ?? @())

    # Exclude krbtgt - it always has SPNs but is not a Kerberoasting target in the
    # traditional sense (its key is the KDC key, not crackable via normal means)
    $accounts = @($allAccounts | Where-Object {
        ($_.SamAccountName ?? '') -ne 'krbtgt'
    })

    if ($accounts.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No Kerberoastable user accounts found (no user accounts with SPNs, excluding krbtgt)' `
            -Details @{ Count = 0 }
    }

    # Separate high-risk (AdminCount > 0) from standard accounts
    $adminAccounts = @($accounts | Where-Object { [int]($_.AdminCount ?? 0) -gt 0 })
    $standardAccounts = @($accounts | Where-Object { [int]($_.AdminCount ?? 0) -eq 0 })

    $now = [datetime]::UtcNow
    $accountDetails = [System.Collections.Generic.List[hashtable]]::new()

    # List admin accounts first (highest risk), then standard
    $ordered = @($adminAccounts) + @($standardAccounts)

    foreach ($acct in $ordered) {
        $pwdAgeDays = $null
        $pwdLastSet = $acct.PwdLastSet
        if ($null -ne $pwdLastSet) {
            $pwdDate = $null
            if ($pwdLastSet -is [datetime]) {
                $pwdDate = $pwdLastSet
            } elseif ($pwdLastSet -is [long] -or $pwdLastSet -is [int64]) {
                if ($pwdLastSet -gt 0) {
                    try { $pwdDate = [datetime]::FromFileTimeUtc($pwdLastSet) } catch { }
                }
            }
            if ($null -ne $pwdDate) {
                $pwdAgeDays = [Math]::Round(($now - $pwdDate).TotalDays, 0)
            }
        }

        $spnCount = @($acct.SPNs ?? @()).Count
        $isAdmin = [int]($acct.AdminCount ?? 0) -gt 0

        $accountDetails.Add(@{
            SamAccountName  = $acct.SamAccountName ?? 'Unknown'
            IsAdmin         = $isAdmin
            SPNCount        = $spnCount
            PasswordAgeDays = $pwdAgeDays
        })
    }

    $currentValue = "$($accounts.Count) Kerberoastable account(s) found"
    if ($adminAccounts.Count -gt 0) {
        $currentValue += " ($($adminAccounts.Count) with AdminCount > 0 - HIGH RISK)"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue $currentValue `
        -Details @{
            TotalCount    = $accounts.Count
            AdminCount    = $adminAccounts.Count
            StandardCount = $standardAccounts.Count
            Accounts      = @($accountDetails)
        }
}

# ── ADKERB-002: Kerberoastable Accounts with Weak Encryption ──────────────
function Test-ReconADKERB002 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $allAccounts = @($kerb.KerberoastableAccounts ?? @())

    # Exclude krbtgt
    $accounts = @($allAccounts | Where-Object {
        ($_.SamAccountName ?? '') -ne 'krbtgt'
    })

    if ($accounts.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No Kerberoastable accounts to evaluate for encryption types' `
            -Details @{ Count = 0 }
    }

    # Encryption type flags:
    # 1 = DES-CBC-CRC, 2 = DES-CBC-MD5, 4 = RC4-HMAC
    # 8 = AES128-CTS-HMAC-SHA1, 16 = AES256-CTS-HMAC-SHA1
    # A value of 0 or null means no explicit encryption type is set, which defaults
    # to RC4-HMAC (the weakest commonly used cipher).
    $FLAG_DES  = 3   # bits 1 + 2
    $FLAG_RC4  = 4
    $FLAG_AES  = 24  # bits 8 + 16

    $weakAccounts = [System.Collections.Generic.List[hashtable]]::new()
    $rc4DefaultAccounts = [System.Collections.Generic.List[hashtable]]::new()
    $desAccounts = [System.Collections.Generic.List[hashtable]]::new()

    foreach ($acct in $accounts) {
        # Read msDS-SupportedEncryptionTypes from available properties
        $encType = 0
        if ($null -ne $acct.EncryptionTypes) {
            $encType = [int]$acct.EncryptionTypes
        } elseif ($null -ne $acct.SupportedEncryptionTypes) {
            $encType = [int]$acct.SupportedEncryptionTypes
        } elseif ($null -ne $acct.EncryptionType) {
            $encType = [int]$acct.EncryptionType
        }

        $hasDES = ($encType -band $FLAG_DES) -ne 0
        $hasRC4 = ($encType -band $FLAG_RC4) -ne 0
        $hasAES = ($encType -band $FLAG_AES) -ne 0

        # If encType is 0 or not set, the account defaults to RC4
        $isDefaultRC4 = ($encType -eq 0)

        $isWeak = $false
        $reason = ''

        if ($hasDES) {
            $isWeak = $true
            $reason = 'DES enabled'
            $desAccounts.Add(@{
                SamAccountName = $acct.SamAccountName ?? 'Unknown'
                EncryptionType = $encType
                AdminCount     = [int]($acct.AdminCount ?? 0)
            })
        }

        if ($isDefaultRC4) {
            $isWeak = $true
            $reason = 'No encryption type set (defaults to RC4)'
            $rc4DefaultAccounts.Add(@{
                SamAccountName = $acct.SamAccountName ?? 'Unknown'
                EncryptionType = $encType
                AdminCount     = [int]($acct.AdminCount ?? 0)
            })
        } elseif ($hasRC4 -and -not $hasAES) {
            $isWeak = $true
            $reason = 'RC4 only (no AES)'
        }

        if ($isWeak) {
            $weakAccounts.Add(@{
                SamAccountName = $acct.SamAccountName ?? 'Unknown'
                EncryptionType = $encType
                HasDES         = $hasDES
                HasRC4         = $hasRC4 -or $isDefaultRC4
                HasAES         = $hasAES
                AdminCount     = [int]($acct.AdminCount ?? 0)
                Reason         = $reason
            })
        }
    }

    if ($weakAccounts.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue "All $($accounts.Count) Kerberoastable account(s) support AES encryption" `
            -Details @{
                TotalChecked = $accounts.Count
                WeakCount    = 0
            }
    }

    $adminWeak = @($weakAccounts | Where-Object { [int]($_.AdminCount ?? 0) -gt 0 })

    $issues = [System.Collections.Generic.List[string]]::new()
    if ($desAccounts.Count -gt 0) {
        $issues.Add("$($desAccounts.Count) account(s) with DES enabled")
    }
    if ($rc4DefaultAccounts.Count -gt 0) {
        $issues.Add("$($rc4DefaultAccounts.Count) account(s) with no encryption type set (defaults to RC4)")
    }
    $rc4OnlyCount = @($weakAccounts | Where-Object { $_.Reason -eq 'RC4 only (no AES)' }).Count
    if ($rc4OnlyCount -gt 0) {
        $issues.Add("$rc4OnlyCount account(s) with RC4 only (no AES)")
    }

    $currentValue = "$($weakAccounts.Count) of $($accounts.Count) Kerberoastable account(s) use weak encryption: $($issues -join '; ')"
    if ($adminWeak.Count -gt 0) {
        $currentValue += " ($($adminWeak.Count) privileged)"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue $currentValue `
        -Details @{
            TotalChecked     = $accounts.Count
            WeakCount        = $weakAccounts.Count
            AdminWeakCount   = $adminWeak.Count
            DESCount         = $desAccounts.Count
            RC4DefaultCount  = $rc4DefaultAccounts.Count
            RC4OnlyCount     = $rc4OnlyCount
            WeakAccounts     = @($weakAccounts)
        }
}

# ── ADKERB-003: AS-REP Roastable Accounts ─────────────────────────────────
function Test-ReconADKERB003 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $accounts = @($kerb.ASREPRoastableAccounts ?? @())

    if ($accounts.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No AS-REP roastable accounts found (no accounts with pre-authentication disabled)' `
            -Details @{ Count = 0 }
    }

    $adminAccounts = @($accounts | Where-Object { [int]($_.AdminCount ?? 0) -gt 0 })

    $accountList = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($acct in $accounts) {
        $pwdAgeDays = $null
        $pwdLastSet = $acct.PwdLastSet
        if ($null -ne $pwdLastSet) {
            $pwdDate = $null
            if ($pwdLastSet -is [datetime]) {
                $pwdDate = $pwdLastSet
            } elseif ($pwdLastSet -is [long] -or $pwdLastSet -is [int64]) {
                if ($pwdLastSet -gt 0) {
                    try { $pwdDate = [datetime]::FromFileTimeUtc($pwdLastSet) } catch { }
                }
            }
            if ($null -ne $pwdDate) {
                $pwdAgeDays = [Math]::Round(([datetime]::UtcNow - $pwdDate).TotalDays, 0)
            }
        }

        $accountList.Add(@{
            SamAccountName  = $acct.SamAccountName ?? 'Unknown'
            IsAdmin         = [int]($acct.AdminCount ?? 0) -gt 0
            PasswordAgeDays = $pwdAgeDays
        })
    }

    $currentValue = "$($accounts.Count) AS-REP roastable account(s) found (pre-authentication disabled)"
    if ($adminAccounts.Count -gt 0) {
        $currentValue += " ($($adminAccounts.Count) with AdminCount > 0 - HIGH RISK)"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue $currentValue `
        -Details @{
            TotalCount = $accounts.Count
            AdminCount = $adminAccounts.Count
            Accounts   = @($accountList)
        }
}

# ── ADKERB-004: Unconstrained Delegation (Computers) ──────────────────────
function Test-ReconADKERB004 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $allUnconstrained = @($kerb.UnconstrainedDelegation ?? @())

    # Filter to computer objects only (the data collector already excludes DCs
    # via the LDAP filter which removes SERVER_TRUST_ACCOUNT objects)
    $computers = @($allUnconstrained | Where-Object {
        $classes = @($_.ObjectClass ?? @())
        ($classes -contains 'computer') -or ($classes -contains 'Computer')
    })

    if ($computers.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No non-DC computers with unconstrained delegation found' `
            -Details @{ Count = 0 }
    }

    $computerDetails = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($comp in $computers) {
        $computerDetails.Add(@{
            SamAccountName = $comp.SamAccountName ?? 'Unknown'
            DnsHostName    = $comp.DnsHostName ?? ''
            DN             = $comp.DN ?? ''
        })
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue "$($computers.Count) non-DC computer(s) with unconstrained delegation. These can be exploited to capture TGTs from any authenticating principal" `
        -Details @{
            Count     = $computers.Count
            Computers = @($computerDetails)
        }
}

# ── ADKERB-005: Unconstrained Delegation (Users) ──────────────────────────
function Test-ReconADKERB005 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $allUnconstrained = @($kerb.UnconstrainedDelegation ?? @())

    # Filter to user objects (objectClass contains 'user' but NOT 'computer',
    # since computer objects also inherit from user)
    $users = @($allUnconstrained | Where-Object {
        $classes = @($_.ObjectClass ?? @())
        ($classes -contains 'user') -and -not ($classes -contains 'computer')
    })

    if ($users.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No user accounts with unconstrained delegation found' `
            -Details @{ Count = 0 }
    }

    $userDetails = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($u in $users) {
        $userDetails.Add(@{
            SamAccountName = $u.SamAccountName ?? 'Unknown'
            DN             = $u.DN ?? ''
        })
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' `
        -CurrentValue "CRITICAL: $($users.Count) user account(s) with unconstrained delegation. User accounts should never have unconstrained delegation as compromise allows TGT theft for any authenticating user" `
        -Details @{
            Count = $users.Count
            Users = @($userDetails)
        }
}

# ── ADKERB-006: Constrained Delegation Review ─────────────────────────────
function Test-ReconADKERB006 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $constrained = @($kerb.ConstrainedDelegation ?? @())

    if ($constrained.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No objects with constrained delegation configured' `
            -Details @{ Count = 0 }
    }

    # Sensitive service classes that, when delegated to on a DC, enable privilege escalation
    $sensitiveServices = @('ldap', 'cifs', 'gc', 'host', 'wsman', 'http', 'krbtgt')

    $delegationDetails = [System.Collections.Generic.List[hashtable]]::new()
    $highRiskCount = 0

    foreach ($obj in $constrained) {
        $targets = @($obj.AllowedToDelegateTo ?? @())
        $objClasses = @($obj.ObjectClass ?? @())

        $highRiskTargets = [System.Collections.Generic.List[string]]::new()
        foreach ($target in $targets) {
            $serviceClass = ($target -split '/')[0].ToLower()
            if ($serviceClass -in $sensitiveServices) {
                $highRiskTargets.Add($target)
            }
        }

        if ($highRiskTargets.Count -gt 0) {
            $highRiskCount++
        }

        $delegationDetails.Add(@{
            Name              = $obj.SamAccountName ?? $obj.Name ?? 'Unknown'
            ObjectClass       = if ($objClasses.Count -gt 0) { $objClasses[-1] } else { 'Unknown' }
            DelegationTargets = $targets
            TargetCount       = $targets.Count
            HighRiskTargets   = @($highRiskTargets)
        })
    }

    $currentValue = "$($constrained.Count) object(s) with constrained delegation configured"
    if ($highRiskCount -gt 0) {
        $currentValue += " ($highRiskCount with delegation to sensitive services - review urgently)"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
        -CurrentValue $currentValue `
        -Details @{
            Count         = $constrained.Count
            HighRiskCount = $highRiskCount
            Delegations   = @($delegationDetails)
        }
}

# ── ADKERB-007: Resource-Based Constrained Delegation (RBCD) ──────────────
function Test-ReconADKERB007 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $rbcd = @($kerb.RBCD ?? @())

    if ($rbcd.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No objects with resource-based constrained delegation (RBCD) configured' `
            -Details @{ Count = 0 }
    }

    $rbcdDetails = [System.Collections.Generic.List[hashtable]]::new()
    foreach ($obj in $rbcd) {
        $principals = @($obj.AllowedPrincipals ?? @())
        $principalNames = [System.Collections.Generic.List[string]]::new()

        foreach ($p in $principals) {
            if ($p -is [hashtable]) {
                $principalNames.Add($p.Identity ?? $p.SID ?? 'Unknown')
            } elseif ($p -is [string]) {
                $principalNames.Add($p)
            } else {
                $principalNames.Add('Unknown')
            }
        }

        $rbcdDetails.Add(@{
            Name              = $obj.SamAccountName ?? $obj.Name ?? 'Unknown'
            AllowedPrincipals = @($principalNames)
            PrincipalCount    = $principalNames.Count
        })
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' `
        -CurrentValue "$($rbcd.Count) object(s) with RBCD configured. Review to ensure no unauthorized delegation paths exist" `
        -Details @{
            Count       = $rbcd.Count
            RBCDObjects = @($rbcdDetails)
        }
}

# ── ADKERB-008: Protocol Transition Abuse Paths ──────────────────────────
function Test-ReconADKERB008 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $t2a4d = @($kerb.ProtocolTransition ?? @())

    if ($t2a4d.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
            -CurrentValue 'No objects with Kerberos protocol transition (T2A4D) flag set' `
            -Details @{ Count = 0 }
    }

    # Build a lookup of objects that also have constrained delegation targets
    $constrainedSet = [System.Collections.Generic.HashSet[string]]::new(
        [StringComparer]::OrdinalIgnoreCase)
    $constrainedObjs = @($kerb.ConstrainedDelegation ?? @())
    foreach ($c in $constrainedObjs) {
        $name = $c.SamAccountName ?? ''
        if ($name) { [void]$constrainedSet.Add($name) }
    }

    $objectDetails = [System.Collections.Generic.List[hashtable]]::new()
    $abusePathCount = 0

    foreach ($obj in $t2a4d) {
        $objClasses = @($obj.ObjectClass ?? @())
        $targets = @($obj.AllowedToDelegateTo ?? @())
        $name = $obj.SamAccountName ?? $obj.Name ?? 'Unknown'

        # An object with T2A4D AND constrained delegation targets has full abuse
        # potential: it can perform S4U2Self to get a forwardable ticket for any
        # user, then S4U2Proxy to the delegation target services.
        $hasConstrainedDelegation = ($targets.Count -gt 0) -or $constrainedSet.Contains($name)

        if ($hasConstrainedDelegation) {
            $abusePathCount++
        }

        $objectDetails.Add(@{
            Name                    = $name
            ObjectClass             = if ($objClasses.Count -gt 0) { $objClasses[-1] } else { 'Unknown' }
            DelegationTargets       = $targets
            HasConstrainedDelegation = $hasConstrainedDelegation
        })
    }

    # FAIL if any T2A4D accounts also have constrained delegation (full abuse path)
    $status = if ($abusePathCount -gt 0) { 'FAIL' } else { 'WARN' }

    $currentValue = "$($t2a4d.Count) object(s) with protocol transition (TrustedToAuthForDelegation) enabled"
    if ($abusePathCount -gt 0) {
        $currentValue += ". $abusePathCount have constrained delegation targets (S4U2Self + S4U2Proxy abuse path)"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue $currentValue `
        -Details @{
            Count          = $t2a4d.Count
            AbusePathCount = $abusePathCount
            Objects        = @($objectDetails)
        }
}

# ── ADKERB-009: Kerberos Encryption Type Audit ───────────────────────────
function Test-ReconADKERB009 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $encTypes = $kerb.EncryptionTypes
    if (-not $encTypes -or -not $encTypes.Summary) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Encryption type data not available'
    }

    # Encryption type bit flags
    $FLAG_DES = 3   # bits 1 (DES-CBC-CRC) + 2 (DES-CBC-MD5)
    $FLAG_RC4 = 4   # RC4-HMAC
    $FLAG_AES = 24  # bits 8 (AES128) + 16 (AES256)

    # ── Analyze DC encryption types ──
    $summary = $encTypes.Summary
    $dcs = @($encTypes.DomainControllers ?? $encTypes.DCs ?? @())
    $totalDCs  = [int]($summary.TotalDCs ?? $dcs.Count)
    $dcDESCount = [int]($summary.DESEnabled ?? 0)
    $dcRC4Count = [int]($summary.RC4Enabled ?? 0)
    $dcAESCount = [int]($summary.AESEnabled ?? 0)

    # Count DCs with RC4 only (no AES)
    $dcRC4OnlyCount = 0
    foreach ($dc in $dcs) {
        $hasAES = [bool]($dc.HasAES ?? $false)
        $hasRC4 = [bool]($dc.HasRC4 ?? $false)
        if ($hasRC4 -and -not $hasAES) {
            $dcRC4OnlyCount++
        }
    }

    # ── Analyze Kerberoastable account encryption types ──
    $kerbAccounts = @($kerb.KerberoastableAccounts ?? @())
    $acctDESCount = 0
    $acctRC4OnlyCount = 0
    $acctNoEncTypeCount = 0
    $acctAESCount = 0

    foreach ($acct in $kerbAccounts) {
        $encVal = 0
        if ($null -ne $acct.SupportedEncryptionTypes) {
            $encVal = [int]$acct.SupportedEncryptionTypes
        } elseif ($null -ne $acct.EncryptionType) {
            $encVal = [int]$acct.EncryptionType
        }

        $hasDES = ($encVal -band $FLAG_DES) -ne 0
        $hasRC4 = ($encVal -band $FLAG_RC4) -ne 0
        $hasAES = ($encVal -band $FLAG_AES) -ne 0

        if ($encVal -eq 0) {
            $acctNoEncTypeCount++
        }
        if ($hasDES) { $acctDESCount++ }
        if (($hasRC4 -or $encVal -eq 0) -and -not $hasAES) { $acctRC4OnlyCount++ }
        if ($hasAES) { $acctAESCount++ }
    }

    # ── Determine overall status ──
    $hasDESAnywhere = ($dcDESCount -gt 0) -or ($acctDESCount -gt 0)
    $hasRC4OnlyAnywhere = ($dcRC4OnlyCount -gt 0) -or ($acctRC4OnlyCount -gt 0)

    $status = if ($hasDESAnywhere -or $hasRC4OnlyAnywhere) { 'FAIL' }
              elseif ($dcRC4Count -gt 0 -or $acctNoEncTypeCount -gt 0) { 'WARN' }
              else { 'PASS' }

    # ── Build summary message ──
    $issues = [System.Collections.Generic.List[string]]::new()
    if ($dcDESCount -gt 0) { $issues.Add("$dcDESCount DC(s) support DES (critically weak)") }
    if ($dcRC4OnlyCount -gt 0) { $issues.Add("$dcRC4OnlyCount DC(s) support only RC4 without AES") }
    if ($acctDESCount -gt 0) { $issues.Add("$acctDESCount SPN account(s) have DES enabled") }
    if ($acctRC4OnlyCount -gt 0) { $issues.Add("$acctRC4OnlyCount SPN account(s) use RC4 only or default to RC4") }
    if ($dcRC4Count -gt 0 -and $status -eq 'WARN') {
        $issues.Add("$dcRC4Count DC(s) still support RC4 alongside AES")
    }
    if ($acctNoEncTypeCount -gt 0 -and $status -eq 'WARN') {
        $issues.Add("$acctNoEncTypeCount SPN account(s) have no encryption type set (defaults to RC4)")
    }

    $currentValue = if ($issues.Count -gt 0) {
        "Encryption issues: $($issues -join '; ')"
    } else {
        "All $totalDCs DC(s) and $($kerbAccounts.Count) SPN account(s) support AES encryption"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue $currentValue `
        -Details @{
            DomainControllers = @{
                TotalDCs     = $totalDCs
                DESEnabled   = $dcDESCount
                RC4Enabled   = $dcRC4Count
                AESEnabled   = $dcAESCount
                RC4OnlyCount = $dcRC4OnlyCount
            }
            SPNAccounts       = @{
                TotalAccounts   = $kerbAccounts.Count
                DESEnabled      = $acctDESCount
                RC4OnlyOrDefault = $acctRC4OnlyCount
                NoEncTypeSet    = $acctNoEncTypeCount
                AESEnabled      = $acctAESCount
            }
            Issues            = @($issues)
        }
}

# ── ADKERB-010: Kerberos Ticket Lifetime ───────────────────────────────────
function Test-ReconADKERB010 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    $policy = $kerb.KerberosPolicy
    if (-not $policy -or $policy.Count -eq 0) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos policy data not available. Verify Default Domain Policy via GPMC for ticket lifetime settings'
    }

    # Check if there was an error reading the policy
    if ($policy.ContainsKey('Error') -and $policy.Error) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue "Could not read Kerberos policy: $($policy.Error)" `
            -Details @{ Error = $policy.Error }
    }

    $maxTicketAge  = $policy.MaxTicketAge
    $maxRenewAge   = $policy.MaxRenewAge
    $maxServiceAge = $policy.MaxServiceAge
    $maxClockSkew  = $policy.MaxClockSkew

    $issues = [System.Collections.Generic.List[string]]::new()

    # MaxTicketAge is in hours; recommended <= 10
    if ($null -ne $maxTicketAge -and [int]$maxTicketAge -gt 10) {
        $issues.Add("MaxTicketAge=${maxTicketAge}h (recommended: <=10h)")
    }
    # MaxRenewAge is in days; recommended <= 7
    if ($null -ne $maxRenewAge -and [int]$maxRenewAge -gt 7) {
        $issues.Add("MaxRenewAge=${maxRenewAge}d (recommended: <=7d)")
    }

    # FAIL if thresholds exceeded, not just WARN
    $status = if ($issues.Count -gt 0) { 'FAIL' } else { 'PASS' }

    $currentValue = "Kerberos policy: MaxTicketAge=$(if ($null -ne $maxTicketAge) { "${maxTicketAge}h" } else { 'N/A' }), " +
        "MaxRenewAge=$(if ($null -ne $maxRenewAge) { "${maxRenewAge}d" } else { 'N/A' }), " +
        "MaxServiceAge=$(if ($null -ne $maxServiceAge) { "${maxServiceAge}min" } else { 'N/A' }), " +
        "MaxClockSkew=$(if ($null -ne $maxClockSkew) { "${maxClockSkew}min" } else { 'N/A' })"

    if ($issues.Count -gt 0) {
        $currentValue += ". Issues: $($issues -join '; ')"
    }

    return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status `
        -CurrentValue $currentValue `
        -Details @{
            MaxTicketAge  = $maxTicketAge
            MaxRenewAge   = $maxRenewAge
            MaxServiceAge = $maxServiceAge
            MaxClockSkew  = $maxClockSkew
            Issues        = @($issues)
        }
}

# ── ADKERB-011: Computer SPN Audit ────────────────────────────────────────
function Test-ReconADKERB011 {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$AuditData,
        [Parameter(Mandatory)][hashtable]$CheckDefinition
    )

    $kerb = $AuditData.Kerberos
    if (-not $kerb) {
        return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' `
            -CurrentValue 'Kerberos configuration data not available'
    }

    # ── Aggregate SPN statistics from all available sources ──

    # User SPNs (Kerberoastable accounts)
    $kerbAccounts = @($kerb.KerberoastableAccounts ?? @())
    $totalUserSPNs = 0
    $userSPNServiceTypes = @{}

    foreach ($acct in $kerbAccounts) {
        $spns = @($acct.SPNs ?? @())
        $totalUserSPNs += $spns.Count

        foreach ($spn in $spns) {
            $serviceClass = ($spn -split '/')[0]
            if ($serviceClass) {
                if ($userSPNServiceTypes.ContainsKey($serviceClass)) {
                    $userSPNServiceTypes[$serviceClass]++
                } else {
                    $userSPNServiceTypes[$serviceClass] = 1
                }
            }
        }
    }

    # Constrained delegation target SPNs
    $constrainedTargets = @($kerb.ConstrainedDelegation ?? @())
    $delegationSPNCount = 0
    $delegationServiceTypes = @{}

    foreach ($obj in $constrainedTargets) {
        $targets = @($obj.AllowedToDelegateTo ?? @())
        $delegationSPNCount += $targets.Count

        foreach ($target in $targets) {
            $serviceClass = ($target -split '/')[0]
            if ($serviceClass) {
                if ($delegationServiceTypes.ContainsKey($serviceClass)) {
                    $delegationServiceTypes[$serviceClass]++
                } else {
                    $delegationServiceTypes[$serviceClass] = 1
                }
            }
        }
    }

    # Merge all service types for overall inventory
    $allServiceTypes = @{}
    foreach ($entry in $userSPNServiceTypes.GetEnumerator()) {
        $allServiceTypes[$entry.Key] = $entry.Value
    }
    foreach ($entry in $delegationServiceTypes.GetEnumerator()) {
        if ($allServiceTypes.ContainsKey($entry.Key)) {
            $allServiceTypes[$entry.Key] += $entry.Value
        } else {
            $allServiceTypes[$entry.Key] = $entry.Value
        }
    }

    # Build sorted service type summary
    $serviceTypeSummary = [ordered]@{}
    foreach ($entry in ($allServiceTypes.GetEnumerator() | Sort-Object Value -Descending)) {
        $serviceTypeSummary[$entry.Key] = $entry.Value
    }

    $totalSPNs = $totalUserSPNs + $delegationSPNCount

    $currentValue = "$totalUserSPNs SPN(s) across $($kerbAccounts.Count) Kerberoastable user account(s). " +
        "$($allServiceTypes.Count) distinct service type(s). " +
        "$delegationSPNCount constrained delegation target SPN(s)"

    # INFO check - report inventory for awareness and documentation
    return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' `
        -CurrentValue $currentValue `
        -Details @{
            TotalSPNs            = $totalSPNs
            TotalUserSPNs        = $totalUserSPNs
            KerberoastableUsers  = $kerbAccounts.Count
            DelegationTargetSPNs = $delegationSPNCount
            DistinctServiceTypes = $allServiceTypes.Count
            ServiceTypes         = $serviceTypeSummary
        }
}