PowerShell Active Directory Audit Report Script

PowerShell Active Directory Audit Report Script

PowerShell Tips Editor 3 min read
PowerShell Active Directory Audit Report Script

Quarterly security reviews require the same AD data every time: who is in privileged groups, which accounts have passwords that never expire, which users have not logged in for 90 days, and which passwords are about to expire. With a PowerShell active directory audit script, this entire data gathering takes two minutes instead of two hours, and the results are consistent and reproducible. This post builds each section of the audit report and combines them into an Excel-ready CSV export.

List Domain Admins and Enterprise Admins

Privileged group membership is the most critical section of any AD audit. Check both direct and nested membership:

Import-Module ActiveDirectory

function Get-PrivilegedGroupMembers {
    param([string[]]$Groups)
    foreach ($group in $Groups) {
        try {
            Get-ADGroupMember -Identity $group -Recursive -ErrorAction Stop |
                Where-Object objectClass -eq 'user' |
                ForEach-Object {
                    $user = Get-ADUser -Identity $_.SamAccountName -Properties EmailAddress, LastLogonDate, Enabled
                    [PSCustomObject]@{
                        Group         = $group
                        Username      = $user.SamAccountName
                        DisplayName   = $user.Name
                        Email         = $user.EmailAddress
                        Enabled       = $user.Enabled
                        LastLogonDate = $user.LastLogonDate
                    }
                }
        }
        catch { Write-Warning "Could not query group $group : $_" }
    }
}

$privilegedGroups = @('Domain Admins','Enterprise Admins','Schema Admins','Administrators')
$privilegedMembers = Get-PrivilegedGroupMembers -Groups $privilegedGroups
Write-Host "Privileged accounts found: $($privilegedMembers.Count)"
$privilegedMembers | Format-Table Group, Username, Enabled -AutoSize

Find Users with Password Never Expires

Service accounts often have PasswordNeverExpires set legitimately, but regular user accounts with this flag are a security risk that auditors frequently flag:

$neverExpires = Get-ADUser -Filter { PasswordNeverExpires -eq $true -and Enabled -eq $true } `
    -Properties PasswordNeverExpires, LastLogonDate, PasswordLastSet, Department |
    Select-Object Name, SamAccountName, Department, LastLogonDate, PasswordLastSet,
        @{N='Section'; E={'Password Never Expires'}}

Write-Host "Active users with Password Never Expires: $($neverExpires.Count)"

Find Stale and Disabled Users

Stale enabled accounts (no logon in 90 days) and disabled accounts that have not been removed are both security risks:

$cutoff = (Get-Date).AddDays(-90)

$staleEnabled = Get-ADUser -Filter { Enabled -eq $true } -Properties LastLogonDate |
    Where-Object { $_.LastLogonDate -lt $cutoff -or $_.LastLogonDate -eq $null } |
    Select-Object Name, SamAccountName, LastLogonDate, DistinguishedName,
        @{N='Section'; E={'Stale — No Logon 90+ Days'}}

$disabledAccounts = Get-ADUser -Filter { Enabled -eq $false } -Properties LastLogonDate, WhenChanged |
    Select-Object Name, SamAccountName, LastLogonDate,
        @{N='DisabledDate'; E={ $_.WhenChanged }},
        @{N='Section'; E={'Disabled Accounts'}}

Write-Host "Stale enabled accounts: $($staleEnabled.Count)"
Write-Host "Disabled accounts: $($disabledAccounts.Count)"

Password Expiry in Next 14 Days

Warn users and admins about passwords expiring soon. This requires the domain’s Maximum Password Age policy:

$maxAge     = (Get-ADDefaultDomainPasswordPolicy).MaxPasswordAge.Days
$expiryDate = (Get-Date).AddDays(14)

$expiringPasswords = Get-ADUser -Filter { Enabled -eq $true -and PasswordNeverExpires -eq $false } `
    -Properties PasswordLastSet, EmailAddress |
    Where-Object {
        $_.PasswordLastSet -and
        $_.PasswordLastSet.AddDays($maxAge) -le $expiryDate
    } |
    Select-Object Name, SamAccountName, EmailAddress,
        @{N='PasswordExpires'; E={ $_.PasswordLastSet.AddDays($maxAge).ToString('yyyy-MM-dd') }},
        @{N='Section'; E={'Password Expiring in 14 Days'}}

Write-Host "Passwords expiring in 14 days: $($expiringPasswords.Count)"

Empty Security Groups

Empty security groups accumulate over time after projects and teams dissolve. They are noise in permission audits and waste administrative attention:

$emptyGroups = Get-ADGroup -Filter { GroupCategory -eq 'Security' } |
    Where-Object { (Get-ADGroupMember -Identity $_.DistinguishedName -ErrorAction SilentlyContinue).Count -eq 0 } |
    Select-Object Name, DistinguishedName,
        @{N='Section'; E={'Empty Security Groups'}}

Write-Host "Empty security groups: $($emptyGroups.Count)"

Export Findings to Excel-Ready CSV

Combine all findings into a single CSV with a Section column so auditors can filter by finding type in Excel:

$reportPath = "C:\Reports\AD-AuditReport_$(Get-Date -Format 'yyyyMMdd').csv"

$allFindings = @(
    $privilegedMembers | Select-Object Section, @{N='Name';E={$_.DisplayName}}, @{N='Username';E={$_.Username}}, @{N='Detail';E={$_.Group}}
    $neverExpires      | Select-Object Section, Name, SamAccountName, @{N='Detail';E={$_.PasswordLastSet}}
    $staleEnabled      | Select-Object Section, Name, SamAccountName, @{N='Detail';E={$_.LastLogonDate}}
    $expiringPasswords | Select-Object Section, Name, SamAccountName, @{N='Detail';E={$_.PasswordExpires}}
    $emptyGroups       | Select-Object Section, Name, @{N='SamAccountName';E={$_.Name}}, @{N='Detail';E={$_.DistinguishedName}}
)

$allFindings | Export-Csv -Path $reportPath -NoTypeInformation
Write-Host "Audit report: $reportPath ($($allFindings.Count) findings)"

Common Errors and Fixes

  • PasswordNeverExpires flag may be overridden by Fine-Grained Policy. The domain-level PasswordNeverExpires attribute on a user object does not account for Fine-Grained Password Policies (PSOs) applied to the user or their groups. Use Get-ADUserResultantPasswordPolicy -Identity $user to get the effective policy for each user in a thorough audit.
  • Privileged group membership not inherited — check nested groups. A user who is a member of a group that is a member of Domain Admins is effectively a domain admin. Always use -Recursive with Get-ADGroupMember for privileged group audits to catch nested membership.

Related Cmdlets / See Also

Wrapping Up

An automated AD audit script consistently finds the same categories of risk: over-privileged accounts, passwords that never expire, stale accounts, and empty groups. Run it monthly rather than quarterly to catch issues before they reach an auditor’s report, and combine the CSV output with your ticketing system to track remediation progress.

Send-Item -To