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.
366 lines
15 KiB
PowerShell
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"
|