Files
ad-probe/src/Invoke-ADAudit.ps1
T
ergosteur 903f64a40b Add executive summary with prioritized risk findings to both reports
Surfaces blank-password, AS-REP roastable, unconstrained delegation,
lockout, stale-account, empty-group, and password-never-expires
counts as a severity-ranked findings table at the top of the report,
ahead of the full detail tables -- useful as a reorg-planning summary.
2026-08-21 16:17:44 -04:00

366 lines
15 KiB
PowerShell

<#
.SYNOPSIS
Read-only Active Directory structure/health audit using the RSAT ActiveDirectory module.
.DESCRIPTION
Domain-joined-machine counterpart to ad_audit.py (the LDAP/ldap3 version). Produces the
same data points -- OUs, users, computers, groups, object type counts -- using
Get-AD* cmdlets instead of raw LDAP. Requires the ActiveDirectory PowerShell module
(RSAT) and only performs reads; no changes are made to the directory.
.PARAMETER Server
Domain controller to query. Defaults to the domain of the current user's logon.
.PARAMETER SearchBase
Distinguished name to scope the search to. Defaults to the domain root.
.PARAMETER StaleDays
Days of inactivity (LastLogonDate) before an enabled account is flagged stale. Default 90.
.PARAMETER Credential
Optional PSCredential to bind with. If omitted, uses the current logon session
(typical when run interactively on a domain-joined machine as a normal user).
.PARAMETER OutDir
Directory to write the Markdown report and raw JSON into. Default .\reports relative
to the current working directory.
.EXAMPLE
.\Invoke-ADAudit.ps1
.EXAMPLE
.\Invoke-ADAudit.ps1 -Server dc01.corp.example.com -SearchBase "OU=Corp,DC=corp,DC=example,DC=com" -StaleDays 120
.EXAMPLE
.\Invoke-ADAudit.ps1 -Credential (Get-Credential)
#>
[CmdletBinding()]
param(
[string]$Server,
[string]$SearchBase,
[int]$StaleDays = 90,
[System.Management.Automation.PSCredential]$Credential,
[string]$OutDir = ".\reports"
)
$ErrorActionPreference = "Stop"
if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
Write-Error "ActiveDirectory module not found. Install RSAT: Add-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0'"
exit 1
}
Import-Module ActiveDirectory -ErrorAction Stop
$adParams = @{}
if ($Server) { $adParams["Server"] = $Server }
if ($Credential) { $adParams["Credential"] = $Credential }
if (-not $SearchBase) {
$domain = Get-ADDomain @adParams
$SearchBase = $domain.DistinguishedName
}
Write-Host "Auditing base: $SearchBase"
if ($Server) { Write-Host "Domain controller: $Server" }
# --- UserAccountControl bit flags ---
$UAC_PASSWD_NOTREQD = 0x0020
$UAC_DONT_EXPIRE_PASSWD = 0x10000
$UAC_SMARTCARD_REQUIRED = 0x40000
$UAC_TRUSTED_FOR_DELEGATION = 0x80000
$UAC_DONT_REQ_PREAUTH = 0x400000
$now = Get-Date
$staleCutoff = $now.AddDays(-$StaleDays)
# ---------------------------------------------------------------------------
# Object type counts (whole subtree)
# ---------------------------------------------------------------------------
Write-Host " - object type counts..."
$allObjects = Get-ADObject -SearchBase $SearchBase -Filter * -Properties objectClass @adParams
$objectCounts = $allObjects | Group-Object -Property { $_.ObjectClass } | Sort-Object Count -Descending |
ForEach-Object { [PSCustomObject]@{ ObjectClass = $_.Name; Count = $_.Count } }
# ---------------------------------------------------------------------------
# OUs
# ---------------------------------------------------------------------------
Write-Host " - organizational units..."
$ous = Get-ADOrganizationalUnit -SearchBase $SearchBase -Filter * -Properties Description, whenCreated @adParams |
ForEach-Object {
[PSCustomObject]@{
DN = $_.DistinguishedName
Name = $_.Name
Description = $_.Description
Created = $_.whenCreated
Depth = ([regex]::Matches($_.DistinguishedName, "OU=")).Count
}
}
# ---------------------------------------------------------------------------
# Users
# ---------------------------------------------------------------------------
Write-Host " - users..."
$userProps = @(
"sAMAccountName", "userPrincipalName", "userAccountControl", "LastLogonDate",
"PasswordLastSet", "whenCreated", "adminCount", "MemberOf", "Enabled", "LockedOut"
)
$users = Get-ADUser -SearchBase $SearchBase -Filter * -Properties $userProps @adParams | ForEach-Object {
$uac = [int]$_.userAccountControl
$lastLogon = $_.LastLogonDate
$neverLoggedOn = -not $lastLogon
$stale = ($_.Enabled) -and $lastLogon -and ($lastLogon -lt $staleCutoff)
[PSCustomObject]@{
DN = $_.DistinguishedName
SamAccountName = $_.sAMAccountName
UPN = $_.userPrincipalName
Disabled = -not $_.Enabled
Locked = [bool]$_.LockedOut
PwdNeverExpires = [bool]($uac -band $UAC_DONT_EXPIRE_PASSWD)
PwdNotRequired = [bool]($uac -band $UAC_PASSWD_NOTREQD)
SmartcardRequired = [bool]($uac -band $UAC_SMARTCARD_REQUIRED)
TrustedForDelegation = [bool]($uac -band $UAC_TRUSTED_FOR_DELEGATION)
KerberosPreAuthDisabled = [bool]($uac -band $UAC_DONT_REQ_PREAUTH)
AdminCount = [bool]($_.adminCount -gt 0)
LastLogon = $lastLogon
NeverLoggedOn = $neverLoggedOn -and $_.Enabled
Stale = $stale
PasswordLastSet = $_.PasswordLastSet
Created = $_.whenCreated
GroupCount = (@($_.MemberOf)).Count
}
}
# ---------------------------------------------------------------------------
# Computers
# ---------------------------------------------------------------------------
Write-Host " - computers..."
$compProps = @("sAMAccountName", "userAccountControl", "LastLogonDate", "OperatingSystem",
"OperatingSystemVersion", "whenCreated", "Enabled")
$computers = Get-ADComputer -SearchBase $SearchBase -Filter * -Properties $compProps @adParams | ForEach-Object {
$lastLogon = $_.LastLogonDate
$stale = ($_.Enabled) -and $lastLogon -and ($lastLogon -lt $staleCutoff)
[PSCustomObject]@{
DN = $_.DistinguishedName
SamAccountName = $_.sAMAccountName
OS = $_.OperatingSystem
OSVersion = $_.OperatingSystemVersion
Disabled = -not $_.Enabled
LastLogon = $lastLogon
Stale = $stale
Created = $_.whenCreated
}
}
# ---------------------------------------------------------------------------
# Groups
# ---------------------------------------------------------------------------
Write-Host " - groups..."
$groups = Get-ADGroup -SearchBase $SearchBase -Filter * -Properties Description, whenCreated, Members, GroupCategory, GroupScope @adParams |
ForEach-Object {
$memberCount = (@($_.Members)).Count
[PSCustomObject]@{
DN = $_.DistinguishedName
SamAccountName = $_.SamAccountName
Type = $_.GroupCategory.ToString()
Scope = $_.GroupScope.ToString()
MemberCount = $memberCount
Empty = ($memberCount -eq 0)
Description = $_.Description
Created = $_.whenCreated
}
}
# ---------------------------------------------------------------------------
# Write raw data
# ---------------------------------------------------------------------------
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
$ts = Get-Date -Format "yyyyMMdd_HHmmss"
$raw = [PSCustomObject]@{
ObjectCounts = $objectCounts
OUs = $ous
Users = $users
Computers = $computers
Groups = $groups
}
$rawPath = Join-Path $OutDir "ad_audit_raw_$ts.json"
$raw | ConvertTo-Json -Depth 6 | Out-File -FilePath $rawPath -Encoding utf8
# ---------------------------------------------------------------------------
# Build Markdown report
# ---------------------------------------------------------------------------
Write-Host " - building report..."
# Aggregate stats up front so the executive summary and the detailed
# sections below both draw from the same computed values.
$maxDepth = ($ous | Measure-Object -Property Depth -Maximum).Maximum
$totalUsers = $users.Count
$disabled = ($users | Where-Object Disabled).Count
$enabled = $totalUsers - $disabled
$locked = ($users | Where-Object Locked).Count
$pwdNeverExpires = ($users | Where-Object PwdNeverExpires).Count
$pwdNotRequired = ($users | Where-Object PwdNotRequired).Count
$neverLoggedOn = ($users | Where-Object NeverLoggedOn).Count
$staleUsers = ($users | Where-Object Stale).Count
$adminCountFlagged = ($users | Where-Object AdminCount).Count
$trustedDeleg = ($users | Where-Object TrustedForDelegation).Count
$noPreauth = ($users | Where-Object KerberosPreAuthDisabled).Count
$totalComp = $computers.Count
$compDisabled = ($computers | Where-Object Disabled).Count
$compStale = ($computers | Where-Object Stale).Count
$totalGroups = $groups.Count
$securityGroups = ($groups | Where-Object { $_.Type -eq "Security" }).Count
$distributionGroups = $totalGroups - $securityGroups
$emptyGroups = ($groups | Where-Object Empty).Count
$sb = New-Object System.Text.StringBuilder
function Add-Line([string]$text = "") { [void]$sb.AppendLine($text) }
Add-Line "# Active Directory Audit Report"
Add-Line ""
Add-Line "- Search base: ``$SearchBase``"
if ($Server) { Add-Line "- Domain controller: ``$Server``" }
Add-Line "- Generated: $($now.ToUniversalTime().ToString("o"))"
Add-Line "- Stale-account threshold: $StaleDays days of inactivity"
Add-Line ""
Add-Line "## Executive Summary"
Add-Line ""
Add-Line "- Users: $totalUsers total ($enabled enabled, $disabled disabled)"
Add-Line "- Computers: $totalComp total ($compDisabled disabled)"
Add-Line "- Groups: $totalGroups total ($securityGroups security, $distributionGroups distribution)"
Add-Line "- Max OU nesting depth: $maxDepth"
Add-Line ""
$findings = New-Object System.Collections.Generic.List[Object]
if ($pwdNotRequired -gt 0) { $findings.Add(@("Critical", "User accounts allowing blank passwords", $pwdNotRequired, "PASSWD_NOTREQD flag set; remove unless there is a specific reason")) }
if ($noPreauth -gt 0) { $findings.Add(@("Critical", "AS-REP roastable accounts (Kerberos pre-auth disabled)", $noPreauth, "Offline password cracking risk; re-enable pre-auth unless required")) }
if ($trustedDeleg -gt 0) { $findings.Add(@("Critical", "Accounts trusted for unconstrained delegation", $trustedDeleg, "High-value targets for credential theft; move to constrained/no delegation")) }
if ($locked -gt 0) { $findings.Add(@("High", "Currently locked-out user accounts", $locked, "May indicate attack activity or stale service credentials")) }
if ($staleUsers -gt 0) { $findings.Add(@("Medium", "Stale enabled user accounts (>$StaleDays days inactive)", $staleUsers, "Candidates for disable/offboarding review")) }
if ($compStale -gt 0) { $findings.Add(@("Medium", "Stale enabled computer accounts (>$StaleDays days inactive)", $compStale, "Likely decommissioned hardware still trusted in the domain")) }
if ($emptyGroups -gt 0) { $findings.Add(@("Medium", "Empty security/distribution groups", $emptyGroups, "Cleanup candidates ahead of OU/group reorg")) }
if ($pwdNeverExpires -gt 0) { $findings.Add(@("Medium", "Accounts with password-never-expires set", $pwdNeverExpires, "Review against password policy; exempt only where justified")) }
if ($neverLoggedOn -gt 0) { $findings.Add(@("Low", "Enabled accounts that have never logged on", $neverLoggedOn, "Possibly unused/orphaned provisioning; verify before disabling")) }
if ($adminCountFlagged -gt 0) { $findings.Add(@("Info", "Accounts with adminCount=1 (current or former privileged)", $adminCountFlagged, "SDProp-protected ACLs persist even after privilege is removed; review membership")) }
$severityOrder = @{ "Critical" = 0; "High" = 1; "Medium" = 2; "Low" = 3; "Info" = 4 }
$findings = $findings | Sort-Object { $severityOrder[$_[0]] }
if ($findings.Count -gt 0) {
Add-Line "### Risk & Cleanup Findings"
Add-Line ""
Add-Line "| Severity | Finding | Count | Notes |"
Add-Line "|---|---|---|---|"
foreach ($f in $findings) { Add-Line "| $($f[0]) | $($f[1]) | $($f[2]) | $($f[3]) |" }
Add-Line ""
} else {
Add-Line "No notable risk or cleanup findings surfaced by this audit's checks."
Add-Line ""
}
Add-Line "## Object Type Counts"
Add-Line ""
Add-Line "| Object Class | Count |"
Add-Line "|---|---|"
foreach ($row in $objectCounts) { Add-Line "| $($row.ObjectClass) | $($row.Count) |" }
Add-Line ""
Add-Line "## Organizational Units"
Add-Line ""
Add-Line "- Total OUs: $($ous.Count)"
Add-Line "- Maximum nesting depth: $maxDepth"
Add-Line ""
Add-Line "| OU DN | Depth | Description |"
Add-Line "|---|---|---|"
foreach ($o in ($ous | Sort-Object DN)) { Add-Line "| $($o.DN) | $($o.Depth) | $($o.Description) |" }
Add-Line ""
Add-Line "## Users"
Add-Line ""
Add-Line "- Total user objects: $totalUsers"
Add-Line "- Enabled: $enabled"
Add-Line "- Disabled: $disabled"
Add-Line "- Currently locked out: $locked"
Add-Line "- Password never expires: $pwdNeverExpires"
Add-Line "- Password not required (blank password allowed): **$pwdNotRequired**"
Add-Line "- Enabled but never logged on: $neverLoggedOn"
Add-Line "- Stale (enabled, inactive > $StaleDays days): $staleUsers"
Add-Line "- adminCount=1 (protected/privileged, incl. historical): $adminCountFlagged"
Add-Line "- Trusted for unconstrained delegation: **$trustedDeleg**"
Add-Line "- Kerberos pre-auth disabled (AS-REP roastable): **$noPreauth**"
Add-Line ""
if ($staleUsers -gt 0) {
Add-Line "### Stale user accounts"
Add-Line ""
Add-Line "| sAMAccountName | Last Logon | DN |"
Add-Line "|---|---|---|"
foreach ($u in ($users | Where-Object Stale | Sort-Object LastLogon)) {
Add-Line "| $($u.SamAccountName) | $($u.LastLogon) | $($u.DN) |"
}
Add-Line ""
}
Add-Line "## Computers"
Add-Line ""
Add-Line "- Total computer objects: $totalComp"
Add-Line "- Disabled: $compDisabled"
Add-Line "- Stale (enabled, inactive > $StaleDays days): $compStale"
Add-Line ""
Add-Line "### OS breakdown"
Add-Line ""
Add-Line "| Operating System | Count |"
Add-Line "|---|---|"
$osGroups = $computers | Group-Object -Property { if ($_.OS) { $_.OS } else { "Unknown" } } | Sort-Object Count -Descending
foreach ($g in $osGroups) { Add-Line "| $($g.Name) | $($g.Count) |" }
Add-Line ""
Add-Line "## Groups"
Add-Line ""
Add-Line "- Total groups: $totalGroups"
Add-Line "- Security groups: $securityGroups"
Add-Line "- Distribution groups: $distributionGroups"
Add-Line "- Empty groups (0 members): $emptyGroups"
Add-Line ""
Add-Line "| Scope | Count |"
Add-Line "|---|---|"
$scopeGroups = $groups | Group-Object -Property Scope | Sort-Object Count -Descending
foreach ($g in $scopeGroups) { Add-Line "| $($g.Name) | $($g.Count) |" }
Add-Line ""
Add-Line "### Largest groups (top 15 by member count)"
Add-Line ""
Add-Line "| Group | Type | Scope | Members |"
Add-Line "|---|---|---|---|"
foreach ($g in ($groups | Sort-Object -Property MemberCount -Descending | Select-Object -First 15)) {
Add-Line "| $($g.SamAccountName) | $($g.Type) | $($g.Scope) | $($g.MemberCount) |"
}
Add-Line ""
if ($emptyGroups -gt 0) {
Add-Line "### Empty groups (candidates for cleanup)"
Add-Line ""
Add-Line "| Group | DN |"
Add-Line "|---|---|"
foreach ($g in ($groups | Where-Object Empty | Sort-Object SamAccountName)) {
Add-Line "| $($g.SamAccountName) | $($g.DN) |"
}
Add-Line ""
}
$reportPath = Join-Path $OutDir "ad_audit_report_$ts.md"
$sb.ToString() | Out-File -FilePath $reportPath -Encoding utf8
Write-Host ""
Write-Host "Done."
Write-Host " Raw data: $rawPath"
Write-Host " Report: $reportPath"