Add AD audit scripts (Python/LDAP and PowerShell/RSAT)

Read-only structural/health audit of Active Directory: OU tree, user
and computer account status/hygiene flags, and group breakdown. Two
equivalent implementations depending on available access -- raw LDAP
via ldap3, or Get-AD* cmdlets via RSAT on a domain-joined machine.
This commit is contained in:
2026-08-21 16:14:28 -04:00
commit 60cbfadbae
5 changed files with 772 additions and 0 deletions
+325
View File
@@ -0,0 +1,325 @@
<#
.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..."
$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 "## 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)"
$maxDepth = ($ous | Measure-Object -Property Depth -Maximum).Maximum
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 ""
$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
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 ""
$totalComp = $computers.Count
$compDisabled = ($computers | Where-Object Disabled).Count
$compStale = ($computers | Where-Object Stale).Count
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 ""
$totalGroups = $groups.Count
$securityGroups = ($groups | Where-Object { $_.Type -eq "Security" }).Count
$distributionGroups = $totalGroups - $securityGroups
$emptyGroups = ($groups | Where-Object Empty).Count
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"