declarative-ad-framework/ad-objects/Get-StaleADObjects.ps1
Damien Coles f172d00514 Initial release: Declarative AD Framework v2.1.0
Infrastructure-as-code framework for Active Directory objects and Group Policy.
Sanitized from production deployment for public sharing.
2026-02-19 17:02:42 +00:00

203 lines
7.5 KiB
PowerShell

# Get-StaleADObjects.ps1
# Read-only reporting script -- discovers stale, orphaned, and unmanaged
# AD objects in managed OUs.
#
# Usage:
# .\Get-StaleADObjects.ps1 # Default 90-day threshold
# .\Get-StaleADObjects.ps1 -StaleDays 60 # Custom threshold
[CmdletBinding()]
param(
[int]$StaleDays = 90
)
$ErrorActionPreference = 'Stop'
$ScriptRoot = $PSScriptRoot
# -------------------------------------------------------------------
# Load definitions (source of truth)
# -------------------------------------------------------------------
$definedUsers = & (Join-Path $ScriptRoot 'users.ps1')
$definedGroups = & (Join-Path $ScriptRoot 'groups.ps1')
$definedOUs = & (Join-Path $ScriptRoot 'ous.ps1')
$managedUserNames = @($definedUsers | ForEach-Object { $_.SamAccountName })
$managedGroupNames = @($definedGroups | ForEach-Object { $_.Name })
$managedOUDNs = @($definedOUs | ForEach-Object { "OU=$($_.Name),$($_.Path)" })
$staleDate = (Get-Date).AddDays(-$StaleDays)
$totalFindings = 0
Write-Host "=== Stale AD Object Report ===" -ForegroundColor Cyan
Write-Host " Threshold: $StaleDays days (before $(Get-Date $staleDate -Format 'yyyy-MM-dd'))" -ForegroundColor DarkGray
Write-Host ''
# -------------------------------------------------------------------
# 1. Stale user accounts (no login in N days)
# -------------------------------------------------------------------
Write-Host "--- Stale User Accounts (no login in $StaleDays days) ---" -ForegroundColor White
$staleCount = 0
foreach ($ouDN in $managedOUDNs) {
$users = Get-ADUser -Filter * -SearchBase $ouDN -SearchScope OneLevel `
-Properties LastLogonDate, Enabled -ErrorAction SilentlyContinue
foreach ($user in $users) {
$isStale = $false
if ($null -eq $user.LastLogonDate) {
# Never logged in -- stale if account is old enough
$created = (Get-ADUser -Identity $user.SamAccountName -Properties WhenCreated).WhenCreated
if ($created -lt $staleDate) {
$isStale = $true
}
} elseif ($user.LastLogonDate -lt $staleDate) {
$isStale = $true
}
if ($isStale -and $user.Enabled) {
$lastLogin = if ($null -eq $user.LastLogonDate) { 'Never' } else { Get-Date $user.LastLogonDate -Format 'yyyy-MM-dd' }
Write-Host " [STALE] $($user.SamAccountName) -- last login: $lastLogin" -ForegroundColor Yellow
$staleCount++
}
}
}
if ($staleCount -eq 0) {
Write-Host " No stale accounts found." -ForegroundColor Green
}
$totalFindings += $staleCount
Write-Host ''
# -------------------------------------------------------------------
# 2. Disabled accounts (not intentionally disabled in users.ps1)
# -------------------------------------------------------------------
Write-Host "--- Unexpectedly Disabled Accounts ---" -ForegroundColor White
$disabledCount = 0
foreach ($ouDN in $managedOUDNs) {
$users = Get-ADUser -Filter { Enabled -eq $false } -SearchBase $ouDN -SearchScope OneLevel `
-ErrorAction SilentlyContinue
foreach ($user in $users) {
# Check if this user is intentionally disabled in definitions
$defined = $definedUsers | Where-Object { $_.SamAccountName -eq $user.SamAccountName }
if ($defined -and $defined.Enabled -eq $false) {
continue # Intentionally disabled
}
Write-Host " [DISABLED] $($user.SamAccountName) -- not marked as disabled in definitions" -ForegroundColor Yellow
$disabledCount++
}
}
if ($disabledCount -eq 0) {
Write-Host " No unexpectedly disabled accounts found." -ForegroundColor Green
}
$totalFindings += $disabledCount
Write-Host ''
# -------------------------------------------------------------------
# 3. Empty groups (zero members in managed OUs)
# -------------------------------------------------------------------
Write-Host "--- Empty Groups ---" -ForegroundColor White
$emptyCount = 0
foreach ($ouDN in $managedOUDNs) {
$groups = Get-ADGroup -Filter * -SearchBase $ouDN -SearchScope OneLevel `
-ErrorAction SilentlyContinue
foreach ($group in $groups) {
$members = @(Get-ADGroupMember -Identity $group.SamAccountName -ErrorAction SilentlyContinue)
if ($members.Count -eq 0) {
Write-Host " [EMPTY] $($group.Name) -- zero members" -ForegroundColor Yellow
$emptyCount++
}
}
}
if ($emptyCount -eq 0) {
Write-Host " No empty groups found." -ForegroundColor Green
}
$totalFindings += $emptyCount
Write-Host ''
# -------------------------------------------------------------------
# 4. Unmanaged users (in managed OUs but not in users.ps1)
# -------------------------------------------------------------------
Write-Host "--- Unmanaged Users (in managed OUs but not in definitions) ---" -ForegroundColor White
$unmanagedUserCount = 0
foreach ($ouDN in $managedOUDNs) {
$users = Get-ADUser -Filter * -SearchBase $ouDN -SearchScope OneLevel `
-ErrorAction SilentlyContinue
foreach ($user in $users) {
if ($user.SamAccountName -notin $managedUserNames) {
Write-Host " [UNMANAGED] $($user.SamAccountName) in $ouDN" -ForegroundColor Yellow
$unmanagedUserCount++
}
}
}
if ($unmanagedUserCount -eq 0) {
Write-Host " All users in managed OUs are defined." -ForegroundColor Green
}
$totalFindings += $unmanagedUserCount
Write-Host ''
# -------------------------------------------------------------------
# 5. Unmanaged groups (in managed OUs but not in groups.ps1)
# -------------------------------------------------------------------
Write-Host "--- Unmanaged Groups (in managed OUs but not in definitions) ---" -ForegroundColor White
$unmanagedGroupCount = 0
foreach ($ouDN in $managedOUDNs) {
$groups = Get-ADGroup -Filter * -SearchBase $ouDN -SearchScope OneLevel `
-ErrorAction SilentlyContinue
foreach ($group in $groups) {
if ($group.Name -notin $managedGroupNames) {
Write-Host " [UNMANAGED] $($group.Name) in $ouDN" -ForegroundColor Yellow
$unmanagedGroupCount++
}
}
}
if ($unmanagedGroupCount -eq 0) {
Write-Host " All groups in managed OUs are defined." -ForegroundColor Green
}
$totalFindings += $unmanagedGroupCount
Write-Host ''
# -------------------------------------------------------------------
# 6. Pending credential files
# -------------------------------------------------------------------
Write-Host "--- Pending Credential Files ---" -ForegroundColor White
$credDir = Join-Path $ScriptRoot '.credentials'
$credCount = 0
if (Test-Path $credDir) {
$credFiles = Get-ChildItem -Path $credDir -Filter '*.txt' -ErrorAction SilentlyContinue
foreach ($f in $credFiles) {
$age = [math]::Round(((Get-Date) - $f.LastWriteTime).TotalHours, 1)
$color = if ($age -gt 24) { 'Red' } else { 'Yellow' }
Write-Host " [PENDING] $($f.Name) -- ${age}h old" -ForegroundColor $color
$credCount++
}
}
if ($credCount -eq 0) {
Write-Host " No pending credential files." -ForegroundColor Green
}
$totalFindings += $credCount
Write-Host ''
# -------------------------------------------------------------------
# Summary
# -------------------------------------------------------------------
if ($totalFindings -eq 0) {
Write-Host "No findings. All managed OUs are clean." -ForegroundColor Green
} else {
Write-Host "$totalFindings finding(s) across all checks." -ForegroundColor Red
}
Write-Host 'Report complete. No changes were made.' -ForegroundColor DarkGray