Private/AD/Checks/Invoke-ADPasswordPolicyChecks.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-ADPasswordPolicyChecks { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData ) $checkDefs = Get-AuditCategoryDefinitions -Category 'ADPasswordPolicyChecks' $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) } # ── ADPWD-001: Default Domain Password Policy Overview ───────────────────── function Test-ReconADPWD001 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dp = $AuditData.PasswordPolicies.DefaultPolicy if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default domain password policy data not available' } $minLen = [int]($dp.MinPasswordLength ?? 0) $complex = [bool]($dp.PasswordComplexity ?? $false) $history = [int]($dp.PasswordHistoryCount ?? 0) $maxAge = $dp.MaxPasswordAge $lockout = [int]($dp.LockoutThreshold ?? 0) $reversible = [bool]($dp.ReversibleEncryption ?? $false) # Determine max age in days (AD stores as negative TimeSpan ticks) $maxAgeDays = 0 if ($maxAge -is [timespan]) { $maxAgeDays = [Math]::Abs($maxAge.TotalDays) } $issues = [System.Collections.Generic.List[string]]::new() if ($minLen -lt 14) { $issues.Add("MinLength=$minLen (requires 14+)") } if (-not $complex) { $issues.Add('Complexity not enabled') } if ($history -lt 24) { $issues.Add("History=$history (requires 24+)") } if ($maxAgeDays -gt 365 -or $maxAgeDays -eq 0) { $issues.Add("MaxAge=$([Math]::Round($maxAgeDays, 0))d (requires <=365)") } if ($reversible) { $issues.Add('Reversible encryption enabled') } if ($lockout -eq 0) { $issues.Add('No account lockout configured') } $status = if ($issues.Count -eq 0) { 'PASS' } else { 'FAIL' } $summary = "MinLen=$minLen, Complexity=$complex, History=$history, MaxAge=$([Math]::Round($maxAgeDays, 0))d, Lockout=$lockout" return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $summary ` -Details @{ MinPasswordLength = $minLen PasswordComplexity = $complex PasswordHistoryCount = $history MaxPasswordAgeDays = [Math]::Round($maxAgeDays, 0) LockoutThreshold = $lockout ReversibleEncryption = $reversible Issues = @($issues) } } # ── ADPWD-002: Fine-Grained Password Policy Enumeration ─────────────────── function Test-ReconADPWD002 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $fgpps = @($AuditData.PasswordPolicies.FineGrainedPolicies ?? @()) if ($fgpps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No fine-grained password policies (FGPPs) defined' ` -Details @{ FGPPCount = 0 } } $policyDetails = [System.Collections.Generic.List[hashtable]]::new() foreach ($fgpp in $fgpps) { $appliesToCount = @($fgpp.AppliesTo ?? @()).Count $policyDetails.Add(@{ Name = $fgpp.Name ?? 'Unknown' Precedence = [int]($fgpp.Precedence ?? 0) MinPasswordLength = [int]($fgpp.MinPasswordLength ?? 0) Complexity = [bool]($fgpp.PasswordComplexity ?? $false) HistoryCount = [int]($fgpp.PasswordHistoryCount ?? 0) AppliesToCount = $appliesToCount }) } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "$($fgpps.Count) fine-grained password policy(ies) defined" ` -Details @{ FGPPCount = $fgpps.Count Policies = @($policyDetails) } } # ── ADPWD-003: FGPP Application Strength ────────────────────────────────── function Test-ReconADPWD003 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $fgpps = @($AuditData.PasswordPolicies.FineGrainedPolicies ?? @()) $dp = $AuditData.PasswordPolicies.DefaultPolicy if ($fgpps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No FGPPs defined; only default policy applies' } if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default policy data not available for comparison' } $dpMinLen = [int]($dp.MinPasswordLength ?? 0) $dpComplex = [bool]($dp.PasswordComplexity ?? $false) $dpHistory = [int]($dp.PasswordHistoryCount ?? 0) $dpMaxAge = if ($dp.MaxPasswordAge -is [timespan]) { [Math]::Abs($dp.MaxPasswordAge.TotalDays) } else { 0 } $weakFgpps = [System.Collections.Generic.List[hashtable]]::new() foreach ($fgpp in $fgpps) { $reasons = [System.Collections.Generic.List[string]]::new() $fMinLen = [int]($fgpp.MinPasswordLength ?? 0) $fComplex = [bool]($fgpp.PasswordComplexity ?? $false) $fHistory = [int]($fgpp.PasswordHistoryCount ?? 0) $fMaxAge = if ($fgpp.MaxPasswordAge -is [timespan]) { [Math]::Abs($fgpp.MaxPasswordAge.TotalDays) } else { 0 } if ($fMinLen -lt $dpMinLen) { $reasons.Add("MinLength $fMinLen < default $dpMinLen") } if ($dpComplex -and -not $fComplex) { $reasons.Add('Complexity disabled vs default enabled') } if ($fHistory -lt $dpHistory) { $reasons.Add("History $fHistory < default $dpHistory") } if ($fMaxAge -gt $dpMaxAge -and $dpMaxAge -gt 0) { $reasons.Add("MaxAge $([Math]::Round($fMaxAge,0))d > default $([Math]::Round($dpMaxAge,0))d") } if ($reasons.Count -gt 0) { $weakFgpps.Add(@{ Name = $fgpp.Name ?? 'Unknown' Reasons = @($reasons) }) } } if ($weakFgpps.Count -gt 0) { $names = @($weakFgpps | ForEach-Object { $_.Name }) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue "$($weakFgpps.Count) FGPP(s) have weaker settings than the default policy: $($names -join ', ')" ` -Details @{ WeakFGPPs = @($weakFgpps) } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "All $($fgpps.Count) FGPP(s) meet or exceed default policy standards" } # ── ADPWD-004: Minimum Password Length ───────────────────────────────────── function Test-ReconADPWD004 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dp = $AuditData.PasswordPolicies.DefaultPolicy if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default domain password policy data not available' } $minLen = [int]($dp.MinPasswordLength ?? 0) $status = if ($minLen -ge 14) { 'PASS' } elseif ($minLen -ge 8) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "Minimum password length: $minLen characters" ` -Details @{ MinPasswordLength = $minLen } } # ── ADPWD-005: Password Complexity Requirement ──────────────────────────── function Test-ReconADPWD005 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dp = $AuditData.PasswordPolicies.DefaultPolicy if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default domain password policy data not available' } $complex = [bool]($dp.PasswordComplexity ?? $false) $status = if ($complex) { 'PASS' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "Password complexity: $(if ($complex) { 'Enabled' } else { 'Disabled' })" ` -Details @{ PasswordComplexity = $complex } } # ── ADPWD-006: Account Lockout Policy ───────────────────────────────────── function Test-ReconADPWD006 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dp = $AuditData.PasswordPolicies.DefaultPolicy if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default domain password policy data not available' } $threshold = [int]($dp.LockoutThreshold ?? 0) $lockoutDurationMin = 0 if ($dp.LockoutDuration -is [timespan]) { $lockoutDurationMin = [Math]::Abs($dp.LockoutDuration.TotalMinutes) } $observationMin = 0 if ($dp.LockoutObservationWindow -is [timespan]) { $observationMin = [Math]::Abs($dp.LockoutObservationWindow.TotalMinutes) } $status = if ($threshold -eq 0) { 'FAIL' } elseif ($threshold -gt 10) { 'WARN' } else { 'PASS' } $currentValue = if ($threshold -eq 0) { 'Account lockout is not configured (unlimited failed attempts allowed)' } else { "Lockout after $threshold failed attempts, duration $([Math]::Round($lockoutDurationMin, 0))min, observation window $([Math]::Round($observationMin, 0))min" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ LockoutThreshold = $threshold LockoutDurationMinutes = [Math]::Round($lockoutDurationMin, 0) ObservationWindowMinutes = [Math]::Round($observationMin, 0) } } # ── ADPWD-007: Password History Count ───────────────────────────────────── function Test-ReconADPWD007 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dp = $AuditData.PasswordPolicies.DefaultPolicy if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default domain password policy data not available' } $history = [int]($dp.PasswordHistoryCount ?? 0) $status = if ($history -ge 24) { 'PASS' } elseif ($history -ge 12) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "Password history: $history passwords remembered" ` -Details @{ PasswordHistoryCount = $history } } # ── ADPWD-008: Maximum Password Age ─────────────────────────────────────── function Test-ReconADPWD008 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dp = $AuditData.PasswordPolicies.DefaultPolicy if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default domain password policy data not available' } $maxAgeDays = 0 if ($dp.MaxPasswordAge -is [timespan]) { $maxAgeDays = [Math]::Abs($dp.MaxPasswordAge.TotalDays) } # A max age of 0 means passwords never expire $status = if ($maxAgeDays -eq 0) { 'FAIL' } elseif ($maxAgeDays -gt 365) { 'FAIL' } elseif ($maxAgeDays -gt 180) { 'WARN' } else { 'PASS' } $currentValue = if ($maxAgeDays -eq 0) { 'Maximum password age: Not set (passwords never expire)' } else { "Maximum password age: $([Math]::Round($maxAgeDays, 0)) days" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ MaxPasswordAgeDays = [Math]::Round($maxAgeDays, 0) } } # ── ADPWD-009: Users with Password Never Expires ────────────────────────── function Test-ReconADPWD009 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $users = @($AuditData.PasswordPolicies.UsersPasswordNeverExpires ?? @()) if ($users.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No users have the password-never-expires flag set' ` -Details @{ Count = 0 } } # Separate admin and non-admin accounts $adminUsers = @($users | Where-Object { [int]($_.AdminCount ?? 0) -gt 0 }) $first20 = @($users | Select-Object -First 20 | ForEach-Object { $_.SamAccountName }) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($users.Count) user(s) have password-never-expires set ($($adminUsers.Count) with AdminCount > 0)" ` -Details @{ TotalCount = $users.Count AdminCount = $adminUsers.Count SampleAccounts = $first20 Truncated = ($users.Count -gt 20) } } # ── ADPWD-010: Blank Passwords (DSInternals) ────────────────────────────── function Test-ReconADPWD010 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dsAvailable = $AuditData.ModuleAvailability.DSInternals -eq $true if (-not $dsAvailable) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Requires DSInternals module for NT hash analysis. Install with: Install-Module DSInternals' ` -Details @{ Reason = 'DSInternals module not available' } } # If DSInternals data was collected and populated $blankPwdUsers = @($AuditData.PasswordPolicies.BlankPasswordUsers ?? @()) if ($blankPwdUsers.Count -gt 0) { $first20 = @($blankPwdUsers | Select-Object -First 20 | ForEach-Object { $_.SamAccountName }) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($blankPwdUsers.Count) account(s) have blank passwords" ` -Details @{ Count = $blankPwdUsers.Count SampleAccounts = $first20 } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No accounts with blank passwords detected' } # ── ADPWD-011: Duplicate Password Hashes (DSInternals) ──────────────────── function Test-ReconADPWD011 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dsAvailable = $AuditData.ModuleAvailability.DSInternals -eq $true if (-not $dsAvailable) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Requires DSInternals module for NT hash analysis. Install with: Install-Module DSInternals' ` -Details @{ Reason = 'DSInternals module not available' } } $dupeGroups = @($AuditData.PasswordPolicies.DuplicateHashGroups ?? @()) if ($dupeGroups.Count -gt 0) { $totalAffected = ($dupeGroups | ForEach-Object { @($_.Accounts).Count } | Measure-Object -Sum).Sum return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$totalAffected account(s) share passwords across $($dupeGroups.Count) group(s) of duplicate hashes" ` -Details @{ DuplicateGroupCount = $dupeGroups.Count TotalAffected = $totalAffected } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No duplicate password hashes detected' } # ── ADPWD-012: Have I Been Pwned Check (DSInternals) ────────────────────── function Test-ReconADPWD012 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dsAvailable = $AuditData.ModuleAvailability.DSInternals -eq $true if (-not $dsAvailable) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Requires DSInternals module for HIBP hash comparison. Install with: Install-Module DSInternals' ` -Details @{ Reason = 'DSInternals module not available' } } $compromised = @($AuditData.PasswordPolicies.HIBPCompromisedUsers ?? @()) if ($compromised.Count -gt 0) { $first20 = @($compromised | Select-Object -First 20 | ForEach-Object { $_.SamAccountName }) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($compromised.Count) account(s) have passwords found in HIBP breach database" ` -Details @{ Count = $compromised.Count SampleAccounts = $first20 } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No accounts with passwords found in HIBP breach database' } # ── ADPWD-013: Custom Dictionary Check (DSInternals) ────────────────────── function Test-ReconADPWD013 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dsAvailable = $AuditData.ModuleAvailability.DSInternals -eq $true if (-not $dsAvailable) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Requires DSInternals module for custom dictionary password analysis. Install with: Install-Module DSInternals' ` -Details @{ Reason = 'DSInternals module not available' } } $dictMatches = @($AuditData.PasswordPolicies.DictionaryMatchUsers ?? @()) if ($dictMatches.Count -gt 0) { $first20 = @($dictMatches | Select-Object -First 20 | ForEach-Object { $_.SamAccountName }) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($dictMatches.Count) account(s) have passwords matching custom dictionary entries" ` -Details @{ Count = $dictMatches.Count SampleAccounts = $first20 } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No accounts with dictionary-based passwords detected' } # ── ADPWD-014: Default/Common Passwords (DSInternals) ───────────────────── function Test-ReconADPWD014 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dsAvailable = $AuditData.ModuleAvailability.DSInternals -eq $true if (-not $dsAvailable) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Requires DSInternals module for common password hash analysis. Install with: Install-Module DSInternals' ` -Details @{ Reason = 'DSInternals module not available' } } $commonPwd = @($AuditData.PasswordPolicies.CommonPasswordUsers ?? @()) if ($commonPwd.Count -gt 0) { $first20 = @($commonPwd | Select-Object -First 20 | ForEach-Object { $_.SamAccountName }) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($commonPwd.Count) account(s) use default or commonly known passwords" ` -Details @{ Count = $commonPwd.Count SampleAccounts = $first20 } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No accounts with default or commonly known passwords detected' } # ── ADPWD-015: Password Age Distribution ────────────────────────────────── function Test-ReconADPWD015 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $users = @($AuditData.PasswordPolicies.UsersPasswordNeverExpires ?? @()) # Also attempt to use a broader user list if available $allUsers = @($AuditData.AllUsers ?? $AuditData.PasswordPolicies.AllUsers ?? $users) if ($allUsers.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No user data available for password age distribution analysis' } $now = [datetime]::UtcNow $under90 = 0 $d90to180 = 0 $d180to365 = 0 $over365 = 0 $neverSet = 0 $totalAnalyzed = 0 foreach ($user in $allUsers) { $totalAnalyzed++ $pwdLastSet = $user.PwdLastSet if ($null -eq $pwdLastSet -or $pwdLastSet -eq 0) { $neverSet++ continue } # Handle both DateTime and FileTime (Int64) formats $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 -eq $pwdDate) { $neverSet++ continue } $ageDays = ($now - $pwdDate).TotalDays if ($ageDays -lt 90) { $under90++ } elseif ($ageDays -lt 180) { $d90to180++ } elseif ($ageDays -lt 365) { $d180to365++ } else { $over365++ } } $over365Pct = if ($totalAnalyzed -gt 0) { [Math]::Round(($over365 / $totalAnalyzed) * 100, 1) } else { 0 } $status = if ($over365Pct -gt 20) { 'WARN' } else { 'PASS' } $currentValue = "Password age distribution across $totalAnalyzed accounts: " + "<90d=$under90, 90-180d=$d90to180, 180-365d=$d180to365, >365d=$over365 ($over365Pct%), never set=$neverSet" return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ TotalAnalyzed = $totalAnalyzed Under90Days = $under90 Days90to180 = $d90to180 Days180to365 = $d180to365 Over365Days = $over365 Over365Pct = $over365Pct NeverSet = $neverSet } } # ── ADPWD-016: LAPS Deployment ──────────────────────────────────────────── function Test-ReconADPWD016 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $pp = $AuditData.PasswordPolicies $lapsDeployed = [bool]($pp.LAPSDeployed ?? $false) if (-not $lapsDeployed) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue 'LAPS is not deployed. Local administrator passwords are not being managed' ` -Details @{ LAPSDeployed = $false LAPSType = $pp.LAPSType ?? 'None' } } $lapsComputers = [int]($pp.LAPSComputers ?? 0) $totalComputers = [int]($pp.TotalComputers ?? 0) $coveragePct = if ($totalComputers -gt 0) { [Math]::Round(($lapsComputers / $totalComputers) * 100, 1) } else { 0 } $status = if ($coveragePct -ge 80) { 'PASS' } else { 'WARN' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "LAPS deployed ($($pp.LAPSType)): $lapsComputers of $totalComputers computers covered ($coveragePct%)" ` -Details @{ LAPSDeployed = $true LAPSType = $pp.LAPSType ?? 'Unknown' LAPSComputers = $lapsComputers TotalComputers = $totalComputers CoveragePercent = $coveragePct } } # ── ADPWD-017: LAPS Password Expiration ─────────────────────────────────── function Test-ReconADPWD017 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $pp = $AuditData.PasswordPolicies $lapsDeployed = [bool]($pp.LAPSDeployed ?? $false) if (-not $lapsDeployed) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'LAPS is not deployed; password expiration check not applicable' ` -Details @{ LAPSDeployed = $false } } # LAPS expiration data would come from GPO or policy analysis $lapsExpiration = $AuditData.PasswordPolicies.LAPSExpirationDays ?? $null if ($null -ne $lapsExpiration) { $status = if ([int]$lapsExpiration -le 30) { 'PASS' } elseif ([int]$lapsExpiration -le 60) { 'WARN' } else { 'WARN' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "LAPS password expiration configured at $lapsExpiration days" ` -Details @{ LAPSExpirationDays = [int]$lapsExpiration LAPSType = $pp.LAPSType ?? 'Unknown' } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "LAPS is deployed ($($pp.LAPSType)). Verify password expiration policy is configured via GPO (recommended: 30 days or less)" ` -Details @{ LAPSType = $pp.LAPSType ?? 'Unknown' Note = 'LAPS expiration settings are configured via Group Policy. Manual verification recommended.' } } # ── ADPWD-018: Windows LAPS vs Legacy LAPS ──────────────────────────────── function Test-ReconADPWD018 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $pp = $AuditData.PasswordPolicies $lapsType = $pp.LAPSType ?? 'None' $description = switch ($lapsType) { 'Windows' { 'Windows LAPS (native) is deployed. This is the recommended modern solution with encrypted password storage and Azure AD support.' } 'Legacy' { 'Legacy Microsoft LAPS is deployed. Consider migrating to Windows LAPS for encrypted storage, Azure AD backup, and DSRM password management.' } 'Both' { 'Both Legacy and Windows LAPS are deployed. This may indicate an ongoing migration. Ensure all systems transition to Windows LAPS.' } 'None' { 'No LAPS solution is deployed. Local administrator passwords are not centrally managed.' } default { "LAPS type: $lapsType" } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue $description ` -Details @{ LAPSType = $lapsType LAPSDeployed = [bool]($pp.LAPSDeployed ?? $false) } } # ── ADPWD-019: Azure AD Password Protection ────────────────────────────── function Test-ReconADPWD019 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Azure AD Password Protection status requires manual verification. Check Azure AD portal > Security > Authentication methods > Password protection for banned password list and on-premises agent deployment' ` -Details @{ Note = 'Azure AD Password Protection configuration is not accessible via standard LDAP queries. Verify that the on-premises proxy agent is deployed on DCs and that custom banned password lists are configured.' ManualSteps = @( 'Check Azure portal > Microsoft Entra ID > Security > Authentication methods > Password protection' 'Verify on-premises proxy agent is installed on domain controllers' 'Confirm custom banned password list is configured' 'Verify mode is set to Enforced (not Audit)' ) } } # ── ADPWD-020: BitLocker Recovery Keys in AD ────────────────────────────── function Test-ReconADPWD020 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $bitlockerKeys = [int]($AuditData.PasswordPolicies.BitLockerKeys ?? 0) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "$bitlockerKeys BitLocker recovery key(s) stored in Active Directory" ` -Details @{ BitLockerKeyCount = $bitlockerKeys Note = if ($bitlockerKeys -eq 0) { 'No BitLocker keys found. Verify whether BitLocker is deployed and configured to back up keys to AD.' } else { 'BitLocker recovery keys are stored in AD. Ensure read access is restricted to authorized administrators only.' } } } # ── ADPWD-021: Lockout Threshold Value ──────────────────────────────────── function Test-ReconADPWD021 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dp = $AuditData.PasswordPolicies.DefaultPolicy if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default domain password policy data not available' } $threshold = [int]($dp.LockoutThreshold ?? 0) $status = if ($threshold -eq 0) { 'FAIL' } elseif ($threshold -gt 10) { 'WARN' } else { 'PASS' } $currentValue = if ($threshold -eq 0) { 'Lockout threshold: 0 (disabled - unlimited failed logon attempts allowed)' } elseif ($threshold -gt 10) { "Lockout threshold: $threshold (too permissive; recommended: 3-10 attempts)" } else { "Lockout threshold: $threshold attempts" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ LockoutThreshold = $threshold } } # ── ADPWD-022: Lockout Observation Window ───────────────────────────────── function Test-ReconADPWD022 { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$AuditData, [Parameter(Mandatory)][hashtable]$CheckDefinition ) $dp = $AuditData.PasswordPolicies.DefaultPolicy if (-not $dp) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Default domain password policy data not available' } $threshold = [int]($dp.LockoutThreshold ?? 0) if ($threshold -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue 'Lockout observation window is moot because lockout threshold is 0 (disabled)' ` -Details @{ LockoutThreshold = 0; ObservationWindowMinutes = 0 } } $observationMin = 0 if ($dp.LockoutObservationWindow -is [timespan]) { $observationMin = [Math]::Abs($dp.LockoutObservationWindow.TotalMinutes) } $status = if ($observationMin -eq 0 -or $observationMin -lt 15) { 'FAIL' } else { 'PASS' } $currentValue = if ($observationMin -eq 0) { 'Lockout observation window: 0 minutes (failed attempt counter never resets based on time)' } else { "Lockout observation window: $([Math]::Round($observationMin, 0)) minutes" } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ ObservationWindowMinutes = [Math]::Round($observationMin, 0) } } |