Private/AD/Checks/Invoke-ADPrivilegedAccountChecks.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-ADPrivilegedAccountChecks { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData ) $checkDefs = Get-AuditCategoryDefinitions -Category 'ADPrivilegedAccountChecks' $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) } # ═════════════════════════════════════════════════════════════════════════════ # Helper: Get the privileged groups hashtable safely # ═════════════════════════════════════════════════════════════════════════════ function Get-PrivilegedGroupMembers { [CmdletBinding()] param( [hashtable]$AuditData, [string]$GroupName ) if (-not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups) { return @() } $groups = $AuditData.PrivilegedAccounts.PrivilegedGroups if ($groups.Contains($GroupName)) { return @($groups[$GroupName]) } return @() } # ═════════════════════════════════════════════════════════════════════════════ # Helper: Get all privileged members deduplicated across all groups # ═════════════════════════════════════════════════════════════════════════════ function Get-AllPrivilegedMembers { [CmdletBinding()] param([hashtable]$AuditData) # Use the pre-computed AllPrivilegedUsers list if available if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.AllPrivilegedUsers) { return @($AuditData.PrivilegedAccounts.AllPrivilegedUsers) } # Fallback: manually deduplicate across all groups if (-not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups) { return @() } $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $members = [System.Collections.Generic.List[hashtable]]::new() foreach ($groupName in $AuditData.PrivilegedAccounts.PrivilegedGroups.Keys) { foreach ($member in @($AuditData.PrivilegedAccounts.PrivilegedGroups[$groupName])) { if (-not $member) { continue } $dn = $member.DistinguishedName if (-not $dn -or $seen.Contains($dn)) { continue } if ($member.ObjectClass -eq 'group' -or $member.IsGroup) { continue } [void]$seen.Add($dn) $members.Add($member) } } return @($members) } # ═════════════════════════════════════════════════════════════════════════════ # Helper: Format member list for details output # ═════════════════════════════════════════════════════════════════════════════ function Format-MemberDetail { [CmdletBinding()] param([array]$Members) return @($Members | ForEach-Object { @{ SamAccountName = $_.SamAccountName Enabled = $_.Enabled ObjectClass = $_.ObjectClass } }) } # ── ADPRIV-001: Domain Admins Enumeration ───────────────────────────────── function Test-ReconADPRIV001 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $members = Get-PrivilegedGroupMembers -AuditData $AuditData -GroupName 'Domain Admins' if ($members.Count -eq 0 -and ( -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups.Contains('Domain Admins'))) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Domain Admins group data not available' } $effectiveMembers = @($members | Where-Object { $_.ObjectClass -ne 'group' -and -not $_.IsGroup }) $groupMembers = @($members | Where-Object { $_.ObjectClass -eq 'group' -or $_.IsGroup }) $totalCount = $effectiveMembers.Count $status = if ($totalCount -le 3) { 'PASS' } elseif ($totalCount -le 5) { 'WARN' } else { 'FAIL' } $currentValue = "Domain Admins has $totalCount effective member(s)" if ($groupMembers.Count -gt 0) { $currentValue += " (plus $($groupMembers.Count) nested group(s))" } $memberNames = @($effectiveMembers | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ MemberCount = $totalCount NestedGroupCount = $groupMembers.Count Members = Format-MemberDetail -Members $effectiveMembers MemberNames = $memberNames } } # ── ADPRIV-002: Enterprise Admins Enumeration ───────────────────────────── function Test-ReconADPRIV002 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $members = Get-PrivilegedGroupMembers -AuditData $AuditData -GroupName 'Enterprise Admins' if ($members.Count -eq 0 -and ( -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups.Contains('Enterprise Admins'))) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Enterprise Admins group data not available' } $effectiveMembers = @($members | Where-Object { $_.ObjectClass -ne 'group' -and -not $_.IsGroup }) $totalCount = $effectiveMembers.Count $status = if ($totalCount -eq 0) { 'PASS' } elseif ($totalCount -le 2) { 'WARN' } else { 'FAIL' } $currentValue = if ($totalCount -eq 0) { 'Enterprise Admins group is empty (recommended state)' } else { "Enterprise Admins has $totalCount member(s). This group should be empty during normal operations" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ MemberCount = $totalCount Members = Format-MemberDetail -Members $effectiveMembers } } # ── ADPRIV-003: Schema Admins Enumeration ───────────────────────────────── function Test-ReconADPRIV003 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $members = Get-PrivilegedGroupMembers -AuditData $AuditData -GroupName 'Schema Admins' if ($members.Count -eq 0 -and ( -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups.Contains('Schema Admins'))) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Schema Admins group data not available' } $effectiveMembers = @($members | Where-Object { $_.ObjectClass -ne 'group' -and -not $_.IsGroup }) $totalCount = $effectiveMembers.Count $status = if ($totalCount -eq 0) { 'PASS' } elseif ($totalCount -eq 1) { 'WARN' } else { 'FAIL' } $currentValue = if ($totalCount -eq 0) { 'Schema Admins group is empty (recommended state)' } else { "Schema Admins has $totalCount member(s). This group should be empty during normal operations" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ MemberCount = $totalCount Members = Format-MemberDetail -Members $effectiveMembers } } # ── ADPRIV-004: Account Operators Enumeration ───────────────────────────── function Test-ReconADPRIV004 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $members = Get-PrivilegedGroupMembers -AuditData $AuditData -GroupName 'Account Operators' if ($members.Count -eq 0 -and ( -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups.Contains('Account Operators'))) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Account Operators group data not available' } $effectiveMembers = @($members | Where-Object { $_.ObjectClass -ne 'group' -and -not $_.IsGroup }) $totalCount = $effectiveMembers.Count $status = if ($totalCount -eq 0) { 'PASS' } else { 'FAIL' } $currentValue = if ($totalCount -eq 0) { 'Account Operators group is empty (recommended)' } else { "Account Operators has $totalCount member(s). This group should be empty; use delegated OU-level permissions instead" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ MemberCount = $totalCount Members = Format-MemberDetail -Members $effectiveMembers } } # ── ADPRIV-005: Server Operators Enumeration ────────────────────────────── function Test-ReconADPRIV005 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $members = Get-PrivilegedGroupMembers -AuditData $AuditData -GroupName 'Server Operators' if ($members.Count -eq 0 -and ( -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups.Contains('Server Operators'))) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Server Operators group data not available' } $effectiveMembers = @($members | Where-Object { $_.ObjectClass -ne 'group' -and -not $_.IsGroup }) $totalCount = $effectiveMembers.Count $status = if ($totalCount -eq 0) { 'PASS' } else { 'FAIL' } $currentValue = if ($totalCount -eq 0) { 'Server Operators group is empty (recommended)' } else { "Server Operators has $totalCount member(s). This group should be empty; members can escalate privileges on DCs" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ MemberCount = $totalCount Members = Format-MemberDetail -Members $effectiveMembers } } # ── ADPRIV-006: Backup Operators Enumeration ────────────────────────────── function Test-ReconADPRIV006 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $members = Get-PrivilegedGroupMembers -AuditData $AuditData -GroupName 'Backup Operators' if ($members.Count -eq 0 -and ( -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups.Contains('Backup Operators'))) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Backup Operators group data not available' } $effectiveMembers = @($members | Where-Object { $_.ObjectClass -ne 'group' -and -not $_.IsGroup }) $totalCount = $effectiveMembers.Count $status = if ($totalCount -eq 0) { 'PASS' } else { 'FAIL' } $currentValue = if ($totalCount -eq 0) { 'Backup Operators group is empty (recommended)' } else { "Backup Operators has $totalCount member(s). Members can extract the AD database (ntds.dit) containing all password hashes" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ MemberCount = $totalCount Members = Format-MemberDetail -Members $effectiveMembers } } # ── ADPRIV-007: Print Operators Enumeration ─────────────────────────────── function Test-ReconADPRIV007 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $members = Get-PrivilegedGroupMembers -AuditData $AuditData -GroupName 'Print Operators' if ($members.Count -eq 0 -and ( -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups.Contains('Print Operators'))) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Print Operators group data not available' } $effectiveMembers = @($members | Where-Object { $_.ObjectClass -ne 'group' -and -not $_.IsGroup }) $totalCount = $effectiveMembers.Count $status = if ($totalCount -eq 0) { 'PASS' } else { 'WARN' } $currentValue = if ($totalCount -eq 0) { 'Print Operators group is empty (recommended)' } else { "Print Operators has $totalCount member(s). Members can load printer drivers on DCs which can execute as SYSTEM" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ MemberCount = $totalCount Members = Format-MemberDetail -Members $effectiveMembers } } # ── ADPRIV-008: DnsAdmins Group Membership ──────────────────────────────── function Test-ReconADPRIV008 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $members = Get-PrivilegedGroupMembers -AuditData $AuditData -GroupName 'DnsAdmins' if ($members.Count -eq 0 -and ( -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups.Contains('DnsAdmins'))) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'DnsAdmins group data not available (group may not exist in this domain)' } $effectiveMembers = @($members | Where-Object { $_.ObjectClass -ne 'group' -and -not $_.IsGroup }) $totalCount = $effectiveMembers.Count if ($totalCount -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'DnsAdmins group is empty (recommended)' ` -Details @{ MemberCount = 0 } } # Any members in DnsAdmins is a concern — this group can load arbitrary DLLs on DC DNS service return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "DnsAdmins has $totalCount member(s). Members can configure the DNS service to load an arbitrary DLL as SYSTEM on DCs" ` -Details @{ MemberCount = $totalCount Members = Format-MemberDetail -Members $effectiveMembers } } # ── ADPRIV-009: Nested Group Membership Analysis ────────────────────────── function Test-ReconADPRIV009 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged group data not available' } $groups = $AuditData.PrivilegedAccounts.PrivilegedGroups $nestedGroups = [System.Collections.Generic.List[hashtable]]::new() foreach ($groupName in $groups.Keys) { $members = @($groups[$groupName]) $groupObjects = @($members | Where-Object { $_.ObjectClass -eq 'group' -or $_.IsGroup }) foreach ($nested in $groupObjects) { $nestedGroups.Add(@{ ParentGroup = $groupName NestedGroupName = $nested.SamAccountName DistinguishedName = $nested.DistinguishedName }) } } if ($nestedGroups.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No nested group memberships found in privileged groups' ` -Details @{ NestedGroupCount = 0 } } $summary = @($nestedGroups | ForEach-Object { "$($_.NestedGroupName) in $($_.ParentGroup)" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($nestedGroups.Count) nested group(s) found in privileged groups: $summary" ` -Details @{ NestedGroupCount = $nestedGroups.Count NestedGroups = @($nestedGroups) } } # ── ADPRIV-010: Privileged Users Password Never Expires ─────────────────── function Test-ReconADPRIV010 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData if ($allPriv.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged member data available' } $flaggedAccounts = @($allPriv | Where-Object { $_.UACFlags -and $_.UACFlags.DONT_EXPIRE_PASSWORD -eq $true }) if ($flaggedAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "None of the $($allPriv.Count) privileged account(s) have Password Never Expires set" ` -Details @{ TotalPrivileged = $allPriv.Count; FlaggedCount = 0 } } $accountNames = @($flaggedAccounts | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($flaggedAccounts.Count) privileged account(s) have Password Never Expires: $accountNames" ` -Details @{ TotalPrivileged = $allPriv.Count FlaggedCount = $flaggedAccounts.Count FlaggedAccounts = @($flaggedAccounts | ForEach-Object { @{ SamAccountName = $_.SamAccountName; Enabled = $_.Enabled } }) } } # ── ADPRIV-011: Privileged Users Password Not Required ──────────────────── function Test-ReconADPRIV011 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData if ($allPriv.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged member data available' } $flaggedAccounts = @($allPriv | Where-Object { $_.UACFlags -and $_.UACFlags.PASSWD_NOTREQD -eq $true }) if ($flaggedAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "None of the $($allPriv.Count) privileged account(s) have PASSWD_NOTREQD set" ` -Details @{ TotalPrivileged = $allPriv.Count; FlaggedCount = 0 } } $accountNames = @($flaggedAccounts | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($flaggedAccounts.Count) privileged account(s) have PASSWD_NOTREQD flag (can have blank password): $accountNames" ` -Details @{ TotalPrivileged = $allPriv.Count FlaggedCount = $flaggedAccounts.Count FlaggedAccounts = @($flaggedAccounts | ForEach-Object { @{ SamAccountName = $_.SamAccountName; Enabled = $_.Enabled } }) } } # ── ADPRIV-012: Privileged Users No Kerberos Pre-Auth ───────────────────── function Test-ReconADPRIV012 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData if ($allPriv.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged member data available' } $flaggedAccounts = @($allPriv | Where-Object { $_.UACFlags -and $_.UACFlags.DONT_REQ_PREAUTH -eq $true }) if ($flaggedAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "None of the $($allPriv.Count) privileged account(s) have Kerberos pre-authentication disabled" ` -Details @{ TotalPrivileged = $allPriv.Count; FlaggedCount = 0 } } $accountNames = @($flaggedAccounts | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($flaggedAccounts.Count) privileged account(s) are vulnerable to AS-REP Roasting (no pre-auth): $accountNames" ` -Details @{ TotalPrivileged = $allPriv.Count FlaggedCount = $flaggedAccounts.Count FlaggedAccounts = @($flaggedAccounts | ForEach-Object { @{ SamAccountName = $_.SamAccountName; Enabled = $_.Enabled } }) } } # ── ADPRIV-013: Privileged Users Reversible Encryption ──────────────────── function Test-ReconADPRIV013 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData if ($allPriv.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged member data available' } $flaggedAccounts = @($allPriv | Where-Object { $_.UACFlags -and $_.UACFlags.ENCRYPTED_TEXT_PWD_ALLOWED -eq $true }) if ($flaggedAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "None of the $($allPriv.Count) privileged account(s) have reversible encryption enabled" ` -Details @{ TotalPrivileged = $allPriv.Count; FlaggedCount = 0 } } $accountNames = @($flaggedAccounts | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($flaggedAccounts.Count) privileged account(s) store passwords with reversible encryption (equivalent to cleartext): $accountNames" ` -Details @{ TotalPrivileged = $allPriv.Count FlaggedCount = $flaggedAccounts.Count FlaggedAccounts = @($flaggedAccounts | ForEach-Object { @{ SamAccountName = $_.SamAccountName; Enabled = $_.Enabled } }) } } # ── ADPRIV-014: Privileged Users DES-Only Kerberos ──────────────────────── function Test-ReconADPRIV014 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData if ($allPriv.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged member data available' } $flaggedAccounts = @($allPriv | Where-Object { $_.UACFlags -and $_.UACFlags.USE_DES_KEY_ONLY -eq $true }) if ($flaggedAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "None of the $($allPriv.Count) privileged account(s) are restricted to DES-only Kerberos encryption" ` -Details @{ TotalPrivileged = $allPriv.Count; FlaggedCount = 0 } } $accountNames = @($flaggedAccounts | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($flaggedAccounts.Count) privileged account(s) use DES-only Kerberos encryption (cryptographically broken): $accountNames" ` -Details @{ TotalPrivileged = $allPriv.Count FlaggedCount = $flaggedAccounts.Count FlaggedAccounts = @($flaggedAccounts | ForEach-Object { @{ SamAccountName = $_.SamAccountName; Enabled = $_.Enabled } }) } } # ── ADPRIV-015: Privileged Accounts No MFA Indicator ────────────────────── function Test-ReconADPRIV015 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData if ($allPriv.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged member data available' } # Filter to user accounts only (not computers) $userAccounts = @($allPriv | Where-Object { $_.ObjectClass -eq 'user' -or $_.ObjectClass -eq 'msDS-GroupManagedServiceAccount' -or $_.ObjectClass -eq 'msDS-ManagedServiceAccount' }) if ($userAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged user accounts found to evaluate' } $smartcardRequired = @($userAccounts | Where-Object { $_.UACFlags -and $_.UACFlags.SMARTCARD_REQUIRED -eq $true }) $noSmartcard = @($userAccounts | Where-Object { -not $_.UACFlags -or $_.UACFlags.SMARTCARD_REQUIRED -ne $true }) if ($noSmartcard.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "All $($userAccounts.Count) privileged user account(s) require smart card for interactive logon" ` -Details @{ TotalPrivilegedUsers = $userAccounts.Count SmartcardRequired = $smartcardRequired.Count } } $status = if ($smartcardRequired.Count -eq 0) { 'FAIL' } else { 'WARN' } $accountNames = @($noSmartcard | ForEach-Object { $_.SamAccountName }) -join ', ' $currentValue = "$($noSmartcard.Count) of $($userAccounts.Count) privileged account(s) do not require smart card logon: $accountNames" return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ TotalPrivilegedUsers = $userAccounts.Count SmartcardRequired = $smartcardRequired.Count WithoutSmartcardCount = $noSmartcard.Count AccountsWithoutSmartcard = @($noSmartcard | ForEach-Object { @{ SamAccountName = $_.SamAccountName; Enabled = $_.Enabled } }) } } # ── ADPRIV-016: Privileged Accounts Weak Passwords ──────────────────────── function Test-ReconADPRIV016 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) # Check if password analysis data is available (requires DSInternals or similar) $privData = $AuditData.PrivilegedAccounts $pwdAnalysis = $null if ($privData -and $privData.ContainsKey('PasswordAnalysis')) { $pwdAnalysis = $privData.PasswordAnalysis } elseif ($AuditData.ContainsKey('PasswordAnalysis')) { $pwdAnalysis = $AuditData.PasswordAnalysis } if ($null -eq $pwdAnalysis) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Password strength analysis not available. This check requires DSInternals or offline ntds.dit analysis to compare password hashes against known weak password dictionaries' ` -Details @{ Note = 'Run Test-PasswordQuality from DSInternals module against ntds.dit to identify weak passwords among privileged accounts' } } # If password analysis data is available, evaluate it $weakAccounts = @() if ($pwdAnalysis -is [hashtable] -and $pwdAnalysis.ContainsKey('WeakPasswords')) { $weakAccounts = @($pwdAnalysis.WeakPasswords) } elseif ($pwdAnalysis -is [array]) { $weakAccounts = @($pwdAnalysis) } if ($weakAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No privileged accounts found with weak passwords' ` -Details @{ WeakPasswordCount = 0 } } $accountNames = @($weakAccounts | ForEach-Object { if ($_ -is [hashtable] -and $_.ContainsKey('SamAccountName')) { $_.SamAccountName } else { "$_" } }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($weakAccounts.Count) privileged account(s) have weak or commonly used passwords: $accountNames" ` -Details @{ WeakPasswordCount = $weakAccounts.Count WeakAccounts = @($weakAccounts) } } # ── ADPRIV-017: Privileged Accounts Old Passwords ───────────────────────── function Test-ReconADPRIV017 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData if ($allPriv.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged member data available' } $now = [datetime]::UtcNow $threshold = 365 # days $oldPasswordAccounts = [System.Collections.Generic.List[hashtable]]::new() foreach ($member in $allPriv) { if (-not $member.PwdLastSet) { continue } $pwdLastSet = $member.PwdLastSet $ageDays = -1 if ($pwdLastSet -is [datetime]) { $ageDays = ($now - $pwdLastSet).TotalDays } elseif ($pwdLastSet -is [long] -or $pwdLastSet -is [int64]) { if ($pwdLastSet -eq 0 -or $pwdLastSet -eq [int64]::MaxValue) { continue } try { $pwdDate = [datetime]::FromFileTimeUtc($pwdLastSet) $ageDays = ($now - $pwdDate).TotalDays } catch { continue } } else { continue } if ($ageDays -gt $threshold) { $oldPasswordAccounts.Add(@{ SamAccountName = $member.SamAccountName PwdAgeDays = [math]::Round($ageDays, 0) Enabled = $member.Enabled }) } } if ($oldPasswordAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "All $($allPriv.Count) privileged account(s) have passwords changed within the last $threshold days" ` -Details @{ TotalPrivileged = $allPriv.Count; OldPasswordCount = 0 } } $accountSummary = @($oldPasswordAccounts | ForEach-Object { "$($_.SamAccountName) ($($_.PwdAgeDays)d)" }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($oldPasswordAccounts.Count) privileged account(s) have passwords older than $threshold days: $accountSummary" ` -Details @{ TotalPrivileged = $allPriv.Count OldPasswordCount = $oldPasswordAccounts.Count ThresholdDays = $threshold OldPasswordAccounts = @($oldPasswordAccounts) } } # ── ADPRIV-018: Privileged Accounts Never Logged In ─────────────────────── function Test-ReconADPRIV018 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData if ($allPriv.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No privileged member data available' } $neverLoggedIn = @($allPriv | Where-Object { $null -eq $_.LastLogonTimestamp -or ($_.LastLogonTimestamp -is [long] -and $_.LastLogonTimestamp -eq 0) -or ($_.LastLogonTimestamp -is [string] -and [string]::IsNullOrWhiteSpace($_.LastLogonTimestamp)) }) if ($neverLoggedIn.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "All $($allPriv.Count) privileged account(s) have logged in at least once" ` -Details @{ TotalPrivileged = $allPriv.Count; NeverLoggedInCount = 0 } } $accountNames = @($neverLoggedIn | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue "$($neverLoggedIn.Count) privileged account(s) have never logged in: $accountNames" ` -Details @{ TotalPrivileged = $allPriv.Count NeverLoggedInCount = $neverLoggedIn.Count NeverLoggedIn = @($neverLoggedIn | ForEach-Object { @{ SamAccountName = $_.SamAccountName; Enabled = $_.Enabled; ObjectClass = $_.ObjectClass } }) } } # ── ADPRIV-019: Disabled Accounts in Privileged Groups ──────────────────── function Test-ReconADPRIV019 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged group data not available' } $groups = $AuditData.PrivilegedAccounts.PrivilegedGroups $disabledInGroups = [System.Collections.Generic.List[hashtable]]::new() foreach ($groupName in $groups.Keys) { $members = @($groups[$groupName]) $disabled = @($members | Where-Object { ($_.ObjectClass -ne 'group' -and -not $_.IsGroup) -and $_.Enabled -eq $false }) foreach ($d in $disabled) { $disabledInGroups.Add(@{ SamAccountName = $d.SamAccountName Group = $groupName ObjectClass = $d.ObjectClass }) } } if ($disabledInGroups.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No disabled accounts found in privileged groups' ` -Details @{ DisabledCount = 0 } } $summary = @($disabledInGroups | ForEach-Object { "$($_.SamAccountName) in $($_.Group)" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($disabledInGroups.Count) disabled account(s) found in privileged groups: $summary" ` -Details @{ DisabledCount = $disabledInGroups.Count DisabledAccounts = @($disabledInGroups) } } # ── ADPRIV-020: AdminSDHolder Protected Object Audit ────────────────────── function Test-ReconADPRIV020 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $adminSDHolder = $null if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.ContainsKey('AdminSDHolderACL')) { $adminSDHolder = $AuditData.PrivilegedAccounts.AdminSDHolderACL } if ($null -eq $adminSDHolder) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'AdminSDHolder ACL data not available' } # Default well-known SIDs that should have ACEs on AdminSDHolder $defaultSids = @( 'S-1-5-18' # SYSTEM 'S-1-5-32-544' # Administrators 'S-1-5-9' # Enterprise Domain Controllers 'S-1-3-0' # Creator Owner 'S-1-5-10' # Self ) # Well-known domain-relative RIDs that are expected $defaultRids = @('500', '512', '516', '518', '519', '498') $nonDefaultAces = [System.Collections.Generic.List[string]]::new() $totalAces = 0 try { $acl = $null if ($adminSDHolder -is [System.DirectoryServices.ActiveDirectorySecurity]) { $acl = $adminSDHolder } if ($acl) { $accessRules = $acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) foreach ($ace in $accessRules) { $totalAces++ $sidString = $ace.IdentityReference.Value # Check if this is a known default SID $isDefault = $sidString -in $defaultSids # Also allow domain SID-relative principals with known RIDs if (-not $isDefault -and $sidString -match '-(\d+)$') { $rid = $Matches[1] if ($rid -in $defaultRids) { $isDefault = $true } } if (-not $isDefault) { $nonDefaultAces.Add("$sidString ($($ace.ActiveDirectoryRights) - $($ace.AccessControlType))") } } } else { # Raw bytes or unparseable — report as WARN return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue 'AdminSDHolder ACL was collected but could not be fully parsed. Manual review recommended using Get-ACL on CN=AdminSDHolder,CN=System,<DomainDN>' ` -Details @{ RawData = $true } } } catch { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue "AdminSDHolder ACL parsing failed: $_. Manual review recommended" ` -Details @{ Error = "$_" } } if ($nonDefaultAces.Count -gt 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($nonDefaultAces.Count) non-default ACE(s) found on AdminSDHolder (total $totalAces ACEs). Non-default entries: $($nonDefaultAces -join '; ')" ` -Details @{ TotalACEs = $totalAces NonDefaultCount = $nonDefaultAces.Count NonDefaultACEs = @($nonDefaultAces) } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "AdminSDHolder ACL contains $totalAces ACE(s), all matching expected defaults" ` -Details @{ TotalACEs = $totalAces NonDefaultCount = 0 } } # ── ADPRIV-021: AdminCount Orphans ──────────────────────────────────────── function Test-ReconADPRIV021 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $orphans = $null if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.ContainsKey('AdminCountOrphans')) { $orphans = @($AuditData.PrivilegedAccounts.AdminCountOrphans) } if ($null -eq $orphans) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'AdminCount orphan data not available' } # Filter out null entries $validOrphans = @($orphans | Where-Object { $null -ne $_ }) if ($validOrphans.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No adminCount orphans found. All accounts with adminCount=1 are current members of protected groups' ` -Details @{ OrphanCount = 0 } } $orphanNames = @($validOrphans | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($validOrphans.Count) account(s) have adminCount=1 but are not in any protected group: $orphanNames" ` -Details @{ OrphanCount = $validOrphans.Count Orphans = @($validOrphans | ForEach-Object { @{ SamAccountName = $_.SamAccountName DistinguishedName = $_.DistinguishedName Enabled = $_.Enabled } }) } } # ── ADPRIV-022: krbtgt Password Age ────────────────────────────────────── function Test-ReconADPRIV022 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $krbtgt = $null if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.ContainsKey('KrbtgtAccount')) { $krbtgt = $AuditData.PrivilegedAccounts.KrbtgtAccount } if (-not $krbtgt) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'krbtgt account data not available' } $pwdAgeDays = -1 if ($krbtgt.ContainsKey('PwdAgeDays')) { $pwdAgeDays = [double]$krbtgt.PwdAgeDays } elseif ($krbtgt.PwdLastSet) { $pwdLastSet = $krbtgt.PwdLastSet if ($pwdLastSet -is [datetime]) { $pwdAgeDays = ([datetime]::UtcNow - $pwdLastSet).TotalDays } elseif ($pwdLastSet -is [long] -or $pwdLastSet -is [int64]) { if ($pwdLastSet -ne 0 -and $pwdLastSet -ne [int64]::MaxValue) { try { $pwdDate = [datetime]::FromFileTimeUtc($pwdLastSet) $pwdAgeDays = ([datetime]::UtcNow - $pwdDate).TotalDays } catch { } } } } if ($pwdAgeDays -lt 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue 'krbtgt password age could not be determined' ` -Details @{ KrbtgtAccount = $krbtgt } } $roundedAge = [math]::Round($pwdAgeDays, 0) $status = if ($roundedAge -le 180) { 'PASS' } elseif ($roundedAge -le 365) { 'WARN' } else { 'FAIL' } $currentValue = "krbtgt password age: $roundedAge days" if ($roundedAge -gt 180) { $currentValue += '. Password should be rotated (twice, with replication time between resets) at least every 180 days' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ PwdAgeDays = $roundedAge KeyVersionNumber = if ($krbtgt.ContainsKey('KeyVersionNumber')) { $krbtgt.KeyVersionNumber } else { 'Unknown' } PwdLastSet = $krbtgt.PwdLastSet } } # ── ADPRIV-023: krbtgt Account Exposure Assessment ─────────────────────── function Test-ReconADPRIV023 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $krbtgt = $null if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.ContainsKey('KrbtgtAccount')) { $krbtgt = $AuditData.PrivilegedAccounts.KrbtgtAccount } if (-not $krbtgt) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'krbtgt account data not available' } $pwdAgeDays = if ($krbtgt.ContainsKey('PwdAgeDays')) { [math]::Round([double]$krbtgt.PwdAgeDays, 0) } else { 'Unknown' } $kvno = if ($krbtgt.ContainsKey('KeyVersionNumber')) { $krbtgt.KeyVersionNumber } else { 'Unknown' } $isDisabled = if ($krbtgt.UACFlags) { $krbtgt.UACFlags.ACCOUNTDISABLE } else { 'Unknown' } $exposureDetails = [System.Collections.Generic.List[string]]::new() # krbtgt should be disabled (it is by default) if ($isDisabled -eq $false) { $exposureDetails.Add('krbtgt account is ENABLED (should be disabled)') } # Check if DES encryption types might be configured if ($krbtgt.UACFlags -and $krbtgt.UACFlags.USE_DES_KEY_ONLY -eq $true) { $exposureDetails.Add('krbtgt is configured for DES-only encryption') } $currentValue = "krbtgt account status: Disabled=$isDisabled, Password age=$pwdAgeDays days, Key version=$kvno" if ($exposureDetails.Count -gt 0) { $currentValue += ". Exposure indicators: $($exposureDetails -join '; ')" return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue $currentValue ` -Details @{ Disabled = $isDisabled PwdAgeDays = $pwdAgeDays KeyVersionNumber = $kvno ExposureItems = @($exposureDetails) WhenCreated = $krbtgt.WhenCreated } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue $currentValue ` -Details @{ Disabled = $isDisabled PwdAgeDays = $pwdAgeDays KeyVersionNumber = $kvno ExposureItems = @() WhenCreated = $krbtgt.WhenCreated } } # ── ADPRIV-024: Service Accounts in Privileged Groups ───────────────────── function Test-ReconADPRIV024 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged group data not available' } $groups = $AuditData.PrivilegedAccounts.PrivilegedGroups $serviceAccountsInGroups = [System.Collections.Generic.List[hashtable]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($groupName in $groups.Keys) { $members = @($groups[$groupName]) foreach ($member in $members) { if (-not $member) { continue } if ($member.ObjectClass -eq 'group' -or $member.IsGroup) { continue } $isServiceAccount = $false # Check IsServiceAccount flag from the collector if ($member.ContainsKey('IsServiceAccount') -and $member.IsServiceAccount) { $isServiceAccount = $true } # Check by object class (gMSA, sMSA) if ($member.ObjectClass -eq 'msDS-GroupManagedServiceAccount' -or $member.ObjectClass -eq 'msDS-ManagedServiceAccount') { $isServiceAccount = $true } # Check SamAccountName patterns commonly used for service accounts if ($member.SamAccountName -match '^svc[_\-\.]' -or $member.SamAccountName -match '[_\-\.]svc$' -or $member.SamAccountName -match '^sa[_\-]' -or $member.SamAccountName -match '^service[_\-]') { $isServiceAccount = $true } # Check if the account has SPNs (indicates it acts as a service) if ($member.ContainsKey('ServicePrincipalName') -and @($member.ServicePrincipalName).Count -gt 0) { $isServiceAccount = $true } if ($isServiceAccount) { # Deduplicate: same account may appear in multiple groups $dedupeKey = "$($member.SamAccountName)|$groupName" if ($seen.Contains($dedupeKey)) { continue } [void]$seen.Add($dedupeKey) $serviceAccountsInGroups.Add(@{ SamAccountName = $member.SamAccountName Group = $groupName ObjectClass = $member.ObjectClass HasSPNs = ($member.ContainsKey('ServicePrincipalName') -and @($member.ServicePrincipalName).Count -gt 0) Enabled = $member.Enabled }) } } } if ($serviceAccountsInGroups.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No service accounts detected in privileged groups' ` -Details @{ ServiceAccountCount = 0 } } $summary = @($serviceAccountsInGroups | ForEach-Object { "$($_.SamAccountName) in $($_.Group)" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($serviceAccountsInGroups.Count) service account(s) found in privileged groups: $summary" ` -Details @{ ServiceAccountCount = $serviceAccountsInGroups.Count ServiceAccounts = @($serviceAccountsInGroups) } } # ── ADPRIV-025: Computer Accounts in Privileged Groups ──────────────────── function Test-ReconADPRIV025 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) if (-not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Privileged group data not available' } $groups = $AuditData.PrivilegedAccounts.PrivilegedGroups $computersInGroups = [System.Collections.Generic.List[hashtable]]::new() foreach ($groupName in $groups.Keys) { $members = @($groups[$groupName]) $computers = @($members | Where-Object { $_.ObjectClass -eq 'computer' -or $_.IsComputer }) foreach ($comp in $computers) { $computersInGroups.Add(@{ SamAccountName = $comp.SamAccountName Group = $groupName DistinguishedName = $comp.DistinguishedName }) } } if ($computersInGroups.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No computer accounts found in privileged groups' ` -Details @{ ComputerAccountCount = 0 } } $summary = @($computersInGroups | ForEach-Object { "$($_.SamAccountName) in $($_.Group)" }) -join '; ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($computersInGroups.Count) computer account(s) found in privileged groups: $summary" ` -Details @{ ComputerAccountCount = $computersInGroups.Count ComputerAccounts = @($computersInGroups) } } # ── ADPRIV-026: Privileged Users Local Logon on DCs ────────────────────── function Test-ReconADPRIV026 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) # This check requires GPO parsing to determine "Allow log on locally" user rights assignment # on the Domain Controllers OU. This data is not collected by Get-ADPrivilegedMembers. return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Local logon rights on DCs require GPO analysis. Verify via Group Policy: Computer Configuration > Windows Settings > Security Settings > Local Policies > User Rights Assignment > Allow log on locally on the Domain Controllers OU GPO' ` -Details @{ Note = 'This check requires GPO SYSVOL parsing or direct DC access to evaluate User Rights Assignment policies' ManualStep = 'Run gpresult /H report.html on a DC and review Allow log on locally setting' } } # ── ADPRIV-027: Privileged Users RDP on DCs ────────────────────────────── function Test-ReconADPRIV027 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) # This check requires GPO parsing to determine "Allow log on through Remote Desktop Services" # user rights assignment on the Domain Controllers OU. return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'RDP access rights on DCs require GPO analysis. Verify via Group Policy: Computer Configuration > Windows Settings > Security Settings > Local Policies > User Rights Assignment > Allow log on through Remote Desktop Services on the Domain Controllers OU GPO' ` -Details @{ Note = 'This check requires GPO SYSVOL parsing or direct DC access to evaluate User Rights Assignment policies' ManualStep = 'Run gpresult /H report.html on a DC and review Allow log on through Remote Desktop Services setting' } } # ── ADPRIV-028: Users with DCSync Rights ────────────────────────────────── function Test-ReconADPRIV028 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) # DCSync requires two extended rights on the domain root: # DS-Replication-Get-Changes = 1131f6aa-9c07-11d1-f79f-00c04fc2dcd2 # DS-Replication-Get-Changes-All = 1131f6ad-9c07-11d1-f79f-00c04fc2dcd2 # Check if DCSync analysis was pre-computed by a domain ACL collector $dcsyncAccounts = $null if ($AuditData.ContainsKey('DCSyncAccounts') -and $null -ne $AuditData.DCSyncAccounts) { $dcsyncAccounts = @($AuditData.DCSyncAccounts) } elseif ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.ContainsKey('DCSyncAccounts')) { $dcsyncAccounts = @($AuditData.PrivilegedAccounts.DCSyncAccounts) } if ($null -ne $dcsyncAccounts) { if ($dcsyncAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No non-default accounts have DCSync replication rights' ` -Details @{ DCSyncAccountCount = 0 } } $accountNames = @($dcsyncAccounts | ForEach-Object { if ($_ -is [hashtable] -and $_.ContainsKey('SamAccountName')) { $_.SamAccountName } else { "$_" } }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($dcsyncAccounts.Count) non-default account(s) have DCSync replication rights: $accountNames" ` -Details @{ DCSyncAccountCount = $dcsyncAccounts.Count DCSyncAccounts = @($dcsyncAccounts) } } # Check if domain-level ACL data is available for parsing $domainACL = $null if ($AuditData.ContainsKey('DomainACL') -and $null -ne $AuditData.DomainACL) { $domainACL = $AuditData.DomainACL } if ($null -eq $domainACL) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Domain root ACL data not available for DCSync rights analysis. Manually audit replication rights using: (Get-ACL "AD:\<DomainDN>").Access | Where-Object {$_.ObjectType -match "1131f6a[a-d]"}' ` -Details @{ Note = 'DCSync rights require domain root ACL which is not collected by the privileged members collector. Add domain ACL analysis or check manually' } } # Parse domain ACL for replication rights $replGuidGetChanges = '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2' $replGuidGetChangesAll = '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2' # Default SIDs that should have replication rights $defaultReplSids = @('S-1-5-32-544', 'S-1-5-9') $defaultReplRids = @('500', '516', '498', '512', '519') $nonDefaultDCSync = [System.Collections.Generic.List[string]]::new() try { $acl = $null if ($domainACL -is [System.DirectoryServices.ActiveDirectorySecurity]) { $acl = $domainACL } if ($acl) { $accessRules = $acl.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier]) # Find principals with both replication rights $hasGetChanges = @{} $hasGetChangesAll = @{} foreach ($ace in $accessRules) { if ($ace.AccessControlType -ne 'Allow') { continue } $objType = $ace.ObjectType.ToString().ToLower() $sid = $ace.IdentityReference.Value if ($objType -eq $replGuidGetChanges) { $hasGetChanges[$sid] = $true } if ($objType -eq $replGuidGetChangesAll) { $hasGetChangesAll[$sid] = $true } } # Find SIDs that have both rights (DCSync capable) foreach ($sid in $hasGetChanges.Keys) { if (-not $hasGetChangesAll.ContainsKey($sid)) { continue } $isDefault = $sid -in $defaultReplSids if (-not $isDefault -and $sid -match '-(\d+)$') { $rid = $Matches[1] if ($rid -in $defaultReplRids) { $isDefault = $true } } if (-not $isDefault) { $nonDefaultDCSync.Add($sid) } } } else { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue 'Domain ACL was collected but could not be parsed for DCSync rights analysis' ` -Details @{ RawData = $true } } } catch { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue "Failed to parse domain ACL for DCSync rights: $_" ` -Details @{ Error = "$_" } } if ($nonDefaultDCSync.Count -gt 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($nonDefaultDCSync.Count) non-default principal(s) have DCSync replication rights: $($nonDefaultDCSync -join ', ')" ` -Details @{ DCSyncAccountCount = $nonDefaultDCSync.Count DCSyncPrincipals = @($nonDefaultDCSync) } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'Only default accounts have DCSync replication rights on the domain root' ` -Details @{ DCSyncAccountCount = 0 } } # ── ADPRIV-029: Protected Users Group Audit ─────────────────────────────── function Test-ReconADPRIV029 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $puMembers = $null if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.ContainsKey('ProtectedUsersMembers')) { $puMembers = @($AuditData.PrivilegedAccounts.ProtectedUsersMembers) } if ($null -eq $puMembers) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Protected Users group data not available' } $validMembers = @($puMembers | Where-Object { $null -ne $_ }) if ($validMembers.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue 'Protected Users group is empty. All Tier 0 privileged accounts should be members for hardened authentication protections' ` -Details @{ MemberCount = 0 } } $memberNames = @($validMembers | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "Protected Users group has $($validMembers.Count) member(s): $memberNames" ` -Details @{ MemberCount = $validMembers.Count Members = @($validMembers | ForEach-Object { @{ SamAccountName = $_.SamAccountName; Enabled = $_.Enabled } }) } } # ── ADPRIV-030: Privileged Users Not in Protected Users ─────────────────── function Test-ReconADPRIV030 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $puMembers = $null if ($AuditData.PrivilegedAccounts -and $AuditData.PrivilegedAccounts.ContainsKey('ProtectedUsersMembers')) { $puMembers = @($AuditData.PrivilegedAccounts.ProtectedUsersMembers) } if ($null -eq $puMembers -or -not $AuditData.PrivilegedAccounts -or -not $AuditData.PrivilegedAccounts.PrivilegedGroups) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Protected Users or privileged group data not available' } # Build set of Protected Users member DNs for fast lookup $puDNs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($pu in @($puMembers)) { if ($pu -and $pu.DistinguishedName) { [void]$puDNs.Add($pu.DistinguishedName) } } # Get all privileged user accounts $allPriv = Get-AllPrivilegedMembers -AuditData $AuditData # Filter to enabled user accounts only (computers and gMSAs should NOT be in Protected Users) $eligibleAccounts = @($allPriv | Where-Object { $_.ObjectClass -eq 'user' -and $_.Enabled -eq $true -and -not $_.IsComputer -and -not $_.IsServiceAccount }) if ($eligibleAccounts.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No eligible privileged user accounts found to evaluate' ` -Details @{ EligibleCount = 0 } } $notProtected = @($eligibleAccounts | Where-Object { -not $puDNs.Contains($_.DistinguishedName) }) if ($notProtected.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "All $($eligibleAccounts.Count) eligible privileged user account(s) are members of the Protected Users group" ` -Details @{ EligibleCount = $eligibleAccounts.Count ProtectedCount = $eligibleAccounts.Count NotProtected = 0 } } $accountNames = @($notProtected | ForEach-Object { $_.SamAccountName }) -join ', ' return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($notProtected.Count) of $($eligibleAccounts.Count) eligible privileged account(s) are NOT in the Protected Users group: $accountNames" ` -Details @{ EligibleCount = $eligibleAccounts.Count ProtectedCount = ($eligibleAccounts.Count - $notProtected.Count) NotProtectedCount = $notProtected.Count NotProtectedAccounts = @($notProtected | ForEach-Object { @{ SamAccountName = $_.SamAccountName; DistinguishedName = $_.DistinguishedName } }) } } |