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.
Table of Contents
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
memberattribute 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
primaryGroupIDpoints 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=1that 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.
-AdditionalGroupadds 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
- Copy the script from the box on this page (or use the download button) and save it as
C:\Scripts\Get-ADPrivilegedGroupReport.ps1. - 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. - 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).
- 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
| Parameter | What it does | Default |
|---|---|---|
-Server | Domain to report on | Current domain |
-AdditionalGroup | Extra groups (name, DN or SID) | None |
-StaleDays | Flag enabled accounts with no logon for this many days | 90 |
-PasswordAgeDays | Flag passwords older than this | 365 |
-SkipOrphans | Skip the adminCount=1 orphan search | Off |
-ExportCsv | Write a CSV file | Not written |
-ExportHtml | Write an HTML report with a per-group summary | Not written |
-PassThru | Send the row objects down the pipeline | Off |
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:
| Column | Meaning |
|---|---|
| Group | Privileged group name, or “(adminCount=1, not in a reported group)” for orphans |
| Member, SamAccountName, ObjectClass | The account (user, computer, gMSA, foreignSecurityPrincipal …) |
| Direct | True if it is a direct member (or the group is its primary group) |
| Via | Nesting path from the privileged group to the account |
| Enabled | From userAccountControl (bit 0x2) |
| LastLogonDate | From lastLogonTimestamp (9 to 14 days behind by design) |
| PasswordLastSet, PasswordAgeDays | From pwdLastSet |
| PasswordNeverExpires | userAccountControl 0x10000 |
| AdminCount | Current adminCount value |
| Flags | Problems found, separated by “; “ |
| Domain, DistinguishedName | Where 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
Get-ADDomainandGet-ADForestgive the domain SID and the forest root domain, so the group SIDs can be built (for example<root domain SID>-519for Enterprise Admins).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.- For each group a queue walks the
memberattribute withGet-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. - An LDAP query
(primaryGroupID=<RID>)adds primary-group members for the domain groups. - An LDAP query
(&(adminCount=1)(|(objectClass=user)(objectClass=computer)))finds protected accounts; any not already seen become orphan rows. - 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
# 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
}
b43dd3bd17e8a64eae8c312be6af89aee5d26856f380b1938be23f9811a72b1fcurl -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 -cInvoke-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.