Emergency server help: get in touch

AD Privileged Group Report: Domain Admins and adminCount Audit

Free PowerShell script that reports every direct and nested member of Domain Admins and other privileged AD groups, plus adminCount=1 orphans.

Version
1.0.0
Last updated
October 6, 2026
Language
PowerShell
Tested on
Run on 6 Oct 2026 on a Windows Server 2025 DC (build 26100.33438, Windows PowerShell 5.1) in our lab domain with a Windows 11 Pro member; syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25.
License
MIT
Pricing
Free

Short answer: run .\Get-ADPrivilegedGroupReport.ps1 -ExportCsv C:\Reports\priv.csv. It lists every account that is a direct or nested member of Domain Admins, Enterprise Admins, Schema Admins, Administrators, Account/Server/Backup/Print Operators, DnsAdmins and Group Policy Creator Owners, with the nesting path, enabled state, last logon and password age. It also finds “orphaned” accounts that still have adminCount=1 but are no longer in any of those groups.

We ran this script on 6 October 2026 on a Windows Server 2025 Standard domain controller (build 26100.33438, September 2026 update, Windows PowerShell 5.1) and a Windows 11 Pro member PC in our lab domain contoso.com. The example output below is from that run; the lab domain is small and new, so your numbers will be bigger. It was also syntax-checked with PowerShell 7.4.6 and PSScriptAnalyzer 1.25.

What it does

Privileged access reviews fail in two common ways: nested groups hide who really has admin rights, and old admin accounts stay enabled for years. This read-only script answers “who can administer this domain, how, and are those accounts still in use?”

  • Groups by SID, not name. Domain Admins (RID 512), Group Policy Creator Owners (520), Enterprise Admins (519) and Schema Admins (518) from the forest root domain, and the built-in groups S-1-5-32-544 (Administrators), -548 (Account Operators), -549 (Server Operators), -550 (Print Operators) and -551 (Backup Operators). Renamed or localised groups are still found. DnsAdmins has no fixed RID, so it is looked up by name.
  • Full nesting, shortest path. Each group’s member attribute is expanded breadth-first. The Via column shows the path, e.g. Administrators > Domain Admins > Tier0-Admins. Circular nesting is detected and not followed twice.
  • Primary group members. Accounts whose primaryGroupID points at a reported group are added; that membership is not stored in the group’s member list, so many scripts miss it.
  • Account health per member. Enabled, LastLogonDate, PasswordLastSet, PasswordAgeDays, PasswordNeverExpires and adminCount, with a Flags column such as “Disabled”, “No logon >90 d”, “Password >365 d”, “PasswordNeverExpires”.
  • adminCount orphans. Users and computers with adminCount=1 that are not in any reported group.
  • Members from other domains. Objects in other domains of the forest are read from their own domain; foreign security principals from trusted forests are shown and flagged.
  • Your own groups. -AdditionalGroup adds groups such as Key Admins, Enterprise Key Admins or a helpdesk tier group.

Why adminCount orphans matter

When an account joins a protected group (Domain Admins, Administrators, the Operators groups and others), the SDProp process copies the permissions of the AdminSDHolder object onto it and sets adminCount to 1. When the account leaves the group, AD does not undo this: the attribute and the protected permissions stay, and the account no longer inherits permissions from its OU. Delegations you set on the OU (for example helpdesk password resets) then silently do not apply to that account.

Some orphans are normal: krbtgt, and members of protected groups this report does not cover (Replicator, Domain Controllers, Read-only Domain Controllers, Key Admins, Enterprise Key Admins). Review the rest: if the account no longer needs admin rights, clear adminCount and re-enable inheritance on its security settings. Microsoft’s list of protected accounts and groups is linked at the end of this page.

Requirements

  • Windows PowerShell 5.1 or PowerShell 7 on Windows, with the ActiveDirectory module (RSAT).
  • Read access to AD in every domain that holds members. Authenticated users can read these attributes by default; no admin rights are needed.
  • Network access to a DC of each domain that appears in the membership (for multi-domain forests).

Download and first run

  1. Copy the script from the box on this page (or use the download button) and save it as C:\Scripts\Get-ADPrivilegedGroupReport.ps1.
  2. If you downloaded the file, remove the “downloaded from the internet” mark so the execution policy lets it run: Unblock-File C:\Scripts\Get-ADPrivilegedGroupReport.ps1.
  3. Run it on a domain-joined machine that has the ActiveDirectory module: a domain controller, a management server, or a Windows 11 PC with RSAT (see Install RSAT on Windows 11).
  4. Read the built-in help once, then run it with no options to see the result on screen before you export anything.
cd C:\Scripts
Get-Help .\Get-ADPrivilegedGroupReport.ps1 -Full
.\Get-ADPrivilegedGroupReport.ps1
.\Get-ADPrivilegedGroupReport.ps1 -ExportCsv C:\Reports\priv.csv -ExportHtml C:\Reports\priv.html

Options

ParameterWhat it doesDefault
-ServerDomain to report onCurrent domain
-AdditionalGroupExtra groups (name, DN or SID)None
-StaleDaysFlag enabled accounts with no logon for this many days90
-PasswordAgeDaysFlag passwords older than this365
-SkipOrphansSkip the adminCount=1 orphan searchOff
-ExportCsvWrite a CSV fileNot written
-ExportHtmlWrite an HTML report with a per-group summaryNot written
-PassThruSend the row objects down the pipelineOff

Usage examples

# Full report on screen
.\Get-ADPrivilegedGroupReport.ps1

# CSV for the auditor, HTML for the manager
.\Get-ADPrivilegedGroupReport.ps1 -ExportCsv C:\Reports\priv.csv -ExportHtml C:\Reports\priv.html

# Include the key admin groups and a stricter logon threshold
.\Get-ADPrivilegedGroupReport.ps1 -AdditionalGroup "Key Admins","Enterprise Key Admins" -StaleDays 60

# Only the problems
.\Get-ADPrivilegedGroupReport.ps1 -PassThru | Where-Object Flags | Sort-Object Group, Member

# Unique people with any admin path (one row per account)
.\Get-ADPrivilegedGroupReport.ps1 -PassThru -SkipOrphans | Sort-Object DistinguishedName -Unique | Select-Object Member, Enabled, LastLogonDate

CSV columns

The example output further down is from our lab run. One row per group and member account:

ColumnMeaning
GroupPrivileged group name, or “(adminCount=1, not in a reported group)” for orphans
Member, SamAccountName, ObjectClassThe account (user, computer, gMSA, foreignSecurityPrincipal …)
DirectTrue if it is a direct member (or the group is its primary group)
ViaNesting path from the privileged group to the account
EnabledFrom userAccountControl (bit 0x2)
LastLogonDateFrom lastLogonTimestamp (9 to 14 days behind by design)
PasswordLastSet, PasswordAgeDaysFrom pwdLastSet
PasswordNeverExpiresuserAccountControl 0x10000
AdminCountCurrent adminCount value
FlagsProblems found, separated by “; “
Domain, DistinguishedNameWhere the account lives

Note that the built-in Administrators group normally contains Domain Admins and Enterprise Admins, so a domain admin appears once under Domain Admins and again under Administrators with Via showing the nesting. That is intentional: each row answers “how does this account get into this group”.

Example output

Run with -ExportCsv C:\srvs-lab\out\priv.csv -ExportHtml C:\srvs-lab\out\priv.html. A few rows of the CSV, trimmed to the main columns:

Group           Member        Direct Via                                                Flags
-----           ------        ------ ---                                                -----
Domain Admins   Alice Jones   True   Domain Admins                                      Never logged on
Domain Admins   Administrator True   Domain Admins
Administrators  Alice Jones   False  Administrators > Domain Admins                     Never logged on
Administrators  Bob Smith     False  Administrators > Server-Admins > IT-Admins > Helpdesk Never logged on

The Via column is the useful part: bob is a domain administrator only through a chain of nested groups, which is easy to miss in Active Directory Users and Computers.

Schedule it

Run it as a scheduled task so the report is waiting for you. A group managed service account (gMSA) is the cleanest identity for this: no password to store and nothing to expire. See our gMSA guide to create one and allow this server to retrieve its password.

$action = New-ScheduledTaskAction -Execute 'powershell.exe' `
    -Argument '-NoProfile -ExecutionPolicy Bypass -File C:\Scripts\Get-ADPrivilegedGroupReport.ps1 -ExportCsv C:\Reports\priv.csv -ExportHtml C:\Reports\priv.html'
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At 7am
$principal = New-ScheduledTaskPrincipal -UserId 'CONTOSO\gmsa-adreport$' -LogonType Password
Register-ScheduledTask -TaskName 'AD privileged group report' -Action $action -Trigger $trigger -Principal $principal

The file is overwritten on every run. If you need history, point the task at a small wrapper script that adds the date to the file name, or copy the file away after each run.

How it works

  1. Get-ADDomain and Get-ADForest give the domain SID and the forest root domain, so the group SIDs can be built (for example <root domain SID>-519 for Enterprise Admins).
  2. Get-ADGroup -Identity <SID> resolves each group. Missing groups (for example Enterprise Admins when you run it in a child domain without access to the root) produce a warning, not a failure.
  3. For each group a queue walks the member attribute with Get-ADObject. Groups go back on the queue with a longer path; accounts become rows. A per-group “seen” list stops loops and duplicate rows. Lookups are cached, so shared nested groups are read once.
  4. An LDAP query (primaryGroupID=<RID>) adds primary-group members for the domain groups.
  5. An LDAP query (&(adminCount=1)(|(objectClass=user)(objectClass=computer))) finds protected accounts; any not already seen become orphan rows.
  6. Flags are worked out from the dates and the userAccountControl bits, and the rows are written out.

Limitations

  • Only group membership is reported. Rights granted another way (delegated OU permissions, GPO user rights assignments, local Administrators on DCs through other paths, DCSync rights) are not.
  • Groups with thousands of direct members have not been tested with this script yet.
  • Foreign security principals from another forest are shown by SID name translation only; their account state in the other forest is not read.
  • The orphan search covers the domain given with -Server; run it once per domain.
  • Not yet run against a live domain (see the note at the top).

Official documentation: Appendix C: Protected accounts and groups in Active Directory · adminCount and SDProp explained (Microsoft archive) · Security identifiers (well-known SIDs)

Related: Active Directory Audit Policy: DC Settings and 35 Key Event IDs · Local Administrators Group Policy: 4 Ways to Control Admin Rights · Delegate Password Reset in Active Directory: Secure 5-Step Helpdesk Setup · Set up Windows LAPS on Windows Server 2025 and Windows 11 · Get-ADUser PowerShell Examples: 25 Queries for Active Directory

See also: Export AD Users to CSV: PowerShell Script with Last Logon · Locked Out AD Users Report: PowerShell Script with Lockout Source · AD Nested Group Membership: PowerShell Tree with Loop Detection · Inactive AD Accounts Report: PowerShell Script with Safe Disable · AD Health Check Report: dcdiag and repadmin PowerShell Script

The script

Get-ADPrivilegedGroupReport.ps1Download
# AD Privileged Group Report: Domain Admins and adminCount Audit (v1.0.0) - from srvScripts.com
# Source, docs and updates: https://srvscripts.com/scripts/ad-privileged-group-report/
# Copyright (c) 2026 srvScripts.com. MIT licence: if you copy, share or adapt this script, keep this notice and credit srvScripts.com.
<#
.SYNOPSIS
    Report every account that is a direct or nested member of the privileged AD groups, plus adminCount=1 orphans.

.DESCRIPTION
    Read-only. Expands the member attribute of each privileged group recursively (breadth-first, so the
    shortest nesting path is reported) and returns one row per group and member account:
      Domain Admins (RID 512), Group Policy Creator Owners (520)         - current domain
      Enterprise Admins (519), Schema Admins (518)                       - forest root domain
      Administrators, Account Operators, Server Operators, Print Operators, Backup Operators
                                                                       (BUILTIN S-1-5-32-544/548/549/550/551)
      DnsAdmins                                                          - looked up by name (no fixed RID)
    Groups are found by SID, so renamed or localised group names still work.
    Users whose primaryGroupID points at a reported group are added too (that membership is not stored in
    the group's member attribute).

    For each account the report shows Enabled, LastLogonDate (lastLogonTimestamp, 9-14 days behind by
    design), PasswordLastSet, PasswordAgeDays, PasswordNeverExpires, adminCount and a Flags column.

    Orphans: accounts with adminCount=1 that are not in any reported group. AdminSDHolder sets adminCount=1
    when an account joins a protected group but does not clear it when the account leaves, so these accounts
    may still carry the protected ACL and no longer inherit permissions. Some are expected (krbtgt, members of
    protected groups not in this report such as Replicator, Key Admins or Domain Controllers).

.PARAMETER Server
    Domain to report on (default: current domain).

.PARAMETER AdditionalGroup
    Extra groups to include (sAMAccountName, DN or SID), e.g. "Key Admins","Helpdesk Tier1".

.PARAMETER StaleDays
    Flag enabled accounts with no logon for this many days (default 90).

.PARAMETER PasswordAgeDays
    Flag passwords older than this many days (default 365).

.PARAMETER SkipOrphans
    Do not search for adminCount=1 orphans.

.PARAMETER ExportCsv
    Write the report to this CSV file (UTF-8).

.PARAMETER ExportHtml
    Write the report to this HTML file.

.PARAMETER PassThru
    Output the row objects to the pipeline.

.EXAMPLE
    .\Get-ADPrivilegedGroupReport.ps1

.EXAMPLE
    .\Get-ADPrivilegedGroupReport.ps1 -ExportCsv C:\Reports\priv.csv -ExportHtml C:\Reports\priv.html

.EXAMPLE
    .\Get-ADPrivilegedGroupReport.ps1 -AdditionalGroup "Key Admins","Enterprise Key Admins" -StaleDays 60

.EXAMPLE
    .\Get-ADPrivilegedGroupReport.ps1 -PassThru | Where-Object Flags -match 'Disabled'

.NOTES
    Name:     Get-ADPrivilegedGroupReport.ps1
    Version:  1.0.0
    Source:   https://srvscripts.com/scripts/ad-privileged-group-report/
    License:  MIT
    Requires: Windows PowerShell 5.1 or PowerShell 7 on Windows, ActiveDirectory module (RSAT), read access to AD
              in every domain that holds members (any authenticated user can normally read these attributes).
#>
[CmdletBinding()]
param(
    [ValidateNotNullOrEmpty()]
    [string]$Server,

    [ValidateNotNullOrEmpty()]
    [string[]]$AdditionalGroup,

    [ValidateRange(1, 3650)]
    [int]$StaleDays = 90,

    [ValidateRange(1, 3650)]
    [int]$PasswordAgeDays = 365,

    [switch]$SkipOrphans,

    [ValidateNotNullOrEmpty()]
    [string]$ExportCsv,

    [ValidateNotNullOrEmpty()]
    [string]$ExportHtml,

    [switch]$PassThru
)

Set-StrictMode -Version 2
$ErrorActionPreference = 'Stop'

if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
    throw "The ActiveDirectory module is not installed. Install RSAT: Active Directory Domain Services tools, then run again."
}
Import-Module ActiveDirectory -Verbose:$false

$now = Get-Date
$props = 'member', 'objectClass', 'objectSid', 'sAMAccountName', 'userAccountControl', 'lastLogonTimestamp',
         'pwdLastSet', 'adminCount', 'primaryGroupID', 'name'

function ConvertFrom-FileTimeValue {
    param($Value)
    if ($null -eq $Value) { return $null }
    $v = [int64]$Value
    if ($v -le 0 -or $v -eq [int64]::MaxValue) { return $null }
    [DateTime]::FromFileTime($v)
}

function Get-AttributeValue {
    param($Entity, [string]$Name)
    if ($Entity.PropertyNames -contains $Name) { return $Entity[$Name].Value }
    $null
}

function Get-DomainFromDN {
    param([string]$DistinguishedName)
    $parts = [regex]::Matches($DistinguishedName, '(?i)(?:^|,)DC=([^,]+)') | ForEach-Object { $_.Groups[1].Value }
    ($parts -join '.')
}

$objCache = @{}
function Get-CachedObject {
    param([string]$DistinguishedName)
    if ($objCache.ContainsKey($DistinguishedName)) { return $objCache[$DistinguishedName] }
    $dom = Get-DomainFromDN $DistinguishedName
    $o = $null
    try {
        $o = Get-ADObject -Identity $DistinguishedName -Server $dom -Properties $props
    } catch {
        Write-Warning "Cannot read '$DistinguishedName' from $dom : $($_.Exception.Message)"
    }
    $objCache[$DistinguishedName] = $o
    $o
}

function Get-LastClass {
    param($Object)
    $c = Get-AttributeValue $Object 'objectClass'
    if ($null -eq $c) { return [string]$Object.ObjectClass }
    [string](@($c)[-1])
}

# ---- Resolve the groups ---------------------------------------------------------------------------------
$domArgs = @{}
if ($Server) { $domArgs.Server = $Server }
$domain = Get-ADDomain @domArgs
$forest = Get-ADForest -Server $domain.DNSRoot
$root = Get-ADDomain -Server $forest.RootDomain
$dSid = $domain.DomainSID.Value
$rSid = $root.DomainSID.Value

$targets = @(
    @{ Sid = "$dSid-512"; Server = $domain.DNSRoot },
    @{ Sid = "$rSid-519"; Server = $root.DNSRoot },
    @{ Sid = "$rSid-518"; Server = $root.DNSRoot },
    @{ Sid = 'S-1-5-32-544'; Server = $domain.DNSRoot },
    @{ Sid = 'S-1-5-32-548'; Server = $domain.DNSRoot },
    @{ Sid = 'S-1-5-32-549'; Server = $domain.DNSRoot },
    @{ Sid = 'S-1-5-32-550'; Server = $domain.DNSRoot },
    @{ Sid = 'S-1-5-32-551'; Server = $domain.DNSRoot },
    @{ Sid = "$dSid-520"; Server = $domain.DNSRoot },
    @{ Name = 'DnsAdmins'; Server = $domain.DNSRoot }
)
foreach ($g in $AdditionalGroup) { $targets += @{ Name = $g; Server = $domain.DNSRoot } }

$groups = [System.Collections.Generic.List[object]]::new()
foreach ($t in $targets) {
    $id = if ($t.ContainsKey('Sid')) { $t.Sid } else { $t.Name }
    try {
        $grp = Get-ADGroup -Identity $id -Server $t.Server -Properties member, objectSid
        $groups.Add($grp)
    } catch {
        Write-Warning "Group '$id' not found in $($t.Server); skipped."
    }
}

# ---- Expand membership (breadth-first, shortest path wins) ----------------------------------------------
$rows = [System.Collections.Generic.List[object]]::new()
$seenMembers = @{}   # DN -> $true for accounts found in any reported group

function ConvertTo-ReportRow {
    param($GroupName, $Object, [string]$Via, [bool]$Direct, [int]$StaleLimit, [int]$PasswordLimit)
    $cls = Get-LastClass $Object
    $memberName = [string](Get-AttributeValue $Object 'name')
    if ($cls -eq 'foreignSecurityPrincipal') {
        try { $memberName = ([System.Security.Principal.SecurityIdentifier]$memberName).Translate([System.Security.Principal.NTAccount]).Value }
        catch { Write-Verbose "Cannot translate foreign SID $memberName" }
    }
    $uac = Get-AttributeValue $Object 'userAccountControl'
    $enabled = $null; $never = $null
    if ($null -ne $uac) { $enabled = -not ([int]$uac -band 2); $never = [bool]([int]$uac -band 0x10000) }
    $last = ConvertFrom-FileTimeValue (Get-AttributeValue $Object 'lastLogonTimestamp')
    $pwdSet = ConvertFrom-FileTimeValue (Get-AttributeValue $Object 'pwdLastSet')
    $age = $null
    if ($pwdSet) { $age = [int]($now - $pwdSet).TotalDays }
    $flags = [System.Collections.Generic.List[string]]::new()
    if ($enabled -eq $false) { $flags.Add('Disabled') }
    if ($enabled -and $cls -ne 'foreignSecurityPrincipal') {
        if (-not $last) { $flags.Add('Never logged on') }
        elseif (($now - $last).TotalDays -gt $StaleLimit) { $flags.Add("No logon >$StaleLimit d") }
    }
    if ($null -ne $age -and $age -gt $PasswordLimit) { $flags.Add("Password >$PasswordLimit d") }
    if ($null -ne $uac -and -not $pwdSet -and $cls -ne 'foreignSecurityPrincipal') { $flags.Add('Must change password / never set') }
    if ($never) { $flags.Add('PasswordNeverExpires') }
    if ($cls -eq 'foreignSecurityPrincipal') { $flags.Add('Foreign principal (other domain/forest)') }
    $adminCount = Get-AttributeValue $Object 'adminCount'
    [pscustomobject]@{
        Group                = $GroupName
        Member               = $memberName
        SamAccountName       = [string](Get-AttributeValue $Object 'sAMAccountName')
        ObjectClass          = $cls
        Direct               = $Direct
        Via                  = $Via
        Enabled              = $enabled
        LastLogonDate        = $last
        PasswordLastSet      = $pwdSet
        PasswordAgeDays      = $age
        PasswordNeverExpires = $never
        AdminCount           = $adminCount
        Flags                = ($flags -join '; ')
        Domain               = Get-DomainFromDN $Object.DistinguishedName
        DistinguishedName    = $Object.DistinguishedName
    }
}

foreach ($grp in $groups) {
    $gName = $grp.Name
    $doneGroups = @{ $grp.DistinguishedName = $true }
    $doneAccounts = @{}
    $queue = New-Object System.Collections.Queue
    $queue.Enqueue(@{ DN = $grp.DistinguishedName; Path = $gName; Depth = 0 })
    while ($queue.Count) {
        $item = $queue.Dequeue()
        $g = Get-CachedObject $item.DN
        if (-not $g) { continue }
        foreach ($m in @(Get-AttributeValue $g 'member')) {
            if (-not $m) { continue }
            $o = Get-CachedObject ([string]$m)
            if (-not $o) { continue }
            $cls = Get-LastClass $o
            if ($cls -eq 'group') {
                if ($doneGroups.ContainsKey($o.DistinguishedName)) { continue }   # loop or already expanded
                $doneGroups[$o.DistinguishedName] = $true
                $queue.Enqueue(@{ DN = $o.DistinguishedName; Path = "$($item.Path) > $($o.Name)"; Depth = $item.Depth + 1 })
                continue
            }
            if ($doneAccounts.ContainsKey($o.DistinguishedName)) { continue }
            $doneAccounts[$o.DistinguishedName] = $true
            $seenMembers[$o.DistinguishedName] = $true
            $rows.Add((ConvertTo-ReportRow -GroupName $gName -Object $o -Via $item.Path -Direct ($item.Depth -eq 0) -StaleLimit $StaleDays -PasswordLimit $PasswordAgeDays))
        }
    }

    # primaryGroupID membership (domain groups only: RID after the domain SID)
    $sid = $grp.SID.Value
    if ($sid -match '^S-1-5-21-.+-(\d+)$') {
        $rid = $Matches[1]
        $gDom = Get-DomainFromDN $grp.DistinguishedName
        $pg = @(Get-ADObject -LDAPFilter "(primaryGroupID=$rid)" -Server $gDom -Properties $props)
        foreach ($o in $pg) {
            if ($doneAccounts.ContainsKey($o.DistinguishedName)) { continue }
            $doneAccounts[$o.DistinguishedName] = $true
            $seenMembers[$o.DistinguishedName] = $true
            $objCache[$o.DistinguishedName] = $o
            $rows.Add((ConvertTo-ReportRow -GroupName $gName -Object $o -Via "$gName (primary group)" -Direct $true -StaleLimit $StaleDays -PasswordLimit $PasswordAgeDays))
        }
    }
    Write-Verbose ("{0}: {1} account(s)" -f $gName, $doneAccounts.Count)
}

# ---- adminCount=1 orphans -------------------------------------------------------------------------------
if (-not $SkipOrphans) {
    $ac = @(Get-ADObject -LDAPFilter '(&(adminCount=1)(|(objectClass=user)(objectClass=computer)))' -Server $domain.DNSRoot -Properties $props)
    foreach ($o in $ac) {
        if ($seenMembers.ContainsKey($o.DistinguishedName)) { continue }
        $r = ConvertTo-ReportRow -GroupName '(adminCount=1, not in a reported group)' -Object $o -Via '' -Direct $false -StaleLimit $StaleDays -PasswordLimit $PasswordAgeDays
        $r.Flags = (@('Orphaned adminCount') + @($r.Flags | Where-Object { $_ })) -join '; '
        $rows.Add($r)
    }
}

$out = @($rows)

# ---- Output ---------------------------------------------------------------------------------------------
if ($ExportCsv) {
    $out | Export-Csv -LiteralPath $ExportCsv -NoTypeInformation -Encoding UTF8
    Write-Information ("{0} row(s) written to {1}" -f $out.Count, $ExportCsv) -InformationAction Continue
}
if ($ExportHtml) {
    $css = 'body{font-family:Segoe UI,Arial,sans-serif;font-size:13px;margin:20px}table{border-collapse:collapse}' +
           'th,td{border:1px solid #ccc;padding:4px 8px;text-align:left}th{background:#f0f0f0}'
    $summary = $out | Group-Object Group | Sort-Object Name | ForEach-Object {
        '<li>{0}: {1} account(s), {2} flagged</li>' -f [System.Net.WebUtility]::HtmlEncode($_.Name), $_.Count, @($_.Group | Where-Object { $_.Flags }).Count
    }
    $pre = "<h1>Privileged group report: $($domain.DNSRoot)</h1><p>Generated {0:yyyy-MM-dd HH:mm}.</p><ul>{1}</ul>" -f $now, ($summary -join '')
    $out | ConvertTo-Html -Title 'Privileged group report' -Head "<style>$css</style>" -PreContent $pre | Out-File -LiteralPath $ExportHtml -Encoding utf8
    Write-Information ("HTML report written to {0}" -f $ExportHtml) -InformationAction Continue
}
if ($PassThru) { return $out }
if (-not $ExportCsv -and -not $ExportHtml) {
    $out | Sort-Object Group, Member | Format-Table Group, Member, ObjectClass, Enabled, LastLogonDate, PasswordAgeDays, Via, Flags -AutoSize -Wrap
}
Version 1.0.0 · SHA-256 b43dd3bd17e8a64eae8c312be6af89aee5d26856f380b1938be23f9811a72b1f
Download and verify on Linux or macOS
curl -fsSL -o Get-ADPrivilegedGroupReport.ps1 https://scr.srvscripts.com/ad-privileged-group-report/Get-ADPrivilegedGroupReport.ps1 && curl -fsSL https://scr.srvscripts.com/ad-privileged-group-report/Get-ADPrivilegedGroupReport.ps1.sha256 | sha256sum -c
Download and verify in Windows PowerShell
Invoke-WebRequest -Uri 'https://scr.srvscripts.com/ad-privileged-group-report/Get-ADPrivilegedGroupReport.ps1' -OutFile 'Get-ADPrivilegedGroupReport.ps1'; if ((Get-FileHash 'Get-ADPrivilegedGroupReport.ps1' -Algorithm SHA256).Hash -eq 'B43DD3BD17E8A64EAE8C312BE6AF89AEE5D26856F380B1938BE23F9811A72B1F') { 'OK: the file is intact' } else { 'MISMATCH: do not run this file' }
Copy the whole line. In Windows PowerShell, curl and sha256sum are not the Linux tools, so use the PowerShell line there.
Also on GitHub: github.com/srvscripts/scripts

Frequently asked questions

How do I list all members of Domain Admins including nested groups?

Get-ADGroupMember “Domain Admins” -Recursive returns the accounts but not how they got there. This script also shows the nesting path, account state and password age for every privileged group.

What does adminCount=1 mean?

The account is, or was, a member of a protected group. SDProp copied the AdminSDHolder permissions onto it. The value is not cleared when the account leaves the group.

How do I fix an orphaned adminCount account?

Confirm it no longer needs admin rights, set adminCount to not set (or 0), and enable permission inheritance on the account in its Advanced Security Settings so OU delegations apply again.

Why does the same admin appear several times?

Each row is one group and one account. Domain Admins are normally nested in Administrators, so an admin shows under both, with the Via column explaining the path.

Does it work in a multi-domain forest?

Yes. Enterprise Admins and Schema Admins are read from the forest root domain and members in other domains are read from their own domain. Run it once per domain for that domain’s groups.

Does the script change any group or account?

No. It only reads AD and writes the files you ask for.

Changelog

  • 1.0.0 — First release.

Free website test

Is your website set up right?

Check SSL, security headers, redirects, robots.txt, sitemap, llms.txt and security.txt in one test. It takes about 30 seconds.