<# .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"