THE PROBLEM
There's a moment in most breach reports that reads something like: the attacker added a compromised account to Domain Admins on day 3 and was not detected until day 47.
Forty-four days. Not because the change was hidden — Event ID 4728 fired correctly in the Security log, exactly as designed. Nobody was reading it.
The uncomfortable part is that this applies to ordinary operations too. Somebody gets added to Domain Admins for a migration in March and is still there in September. A vendor gets Backup Operators for an install and nobody removes it. An account lands in Schema Admins during an Exchange upgrade and stays for the life of the domain.
Every one of those is a legitimate change made for a real reason. Collectively they mean your privileged groups look nothing like what you'd design if you started fresh today, and nobody can tell you when any given membership appeared.
This week we build the watcher. Not "review your groups quarterly" — an actual daily diff that emails you the moment a privileged group changes.
THE SCRIPT
Save the script below to:
C:\Scripts\PrivilegedGroupMonitor.ps1powershell
# Privileged AD Group Membership Monitor
# Automate & Operate — automateandoperate.com
Import-Module ActiveDirectory
# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)
$logFile = "C:\Logs\PrivilegedGroupMonitor.log"
$reportFile = "C:\Logs\PrivilegedGroupReport.csv"
$baselineFile = "C:\Logs\PrivilegedGroupBaseline.csv"
# Groups to monitor — these are the built-in high-privilege groups.
# Add your own tier-0 groups: server admin groups, backup software service groups,
# helpdesk groups with password reset rights on privileged OUs.
$monitoredGroups = @(
"Domain Admins",
"Enterprise Admins",
"Schema Admins",
"Administrators",
"Account Operators",
"Backup Operators",
"Server Operators",
"Print Operators",
"Group Policy Creator Owners",
"DnsAdmins",
"Cert Publishers",
"Remote Desktop Users"
)
# Flag accounts in privileged groups that haven't logged in within this many days
$staleAccountDays = 90
# Flag privileged accounts whose password is older than this many days
$oldPasswordDays = 365
# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
New-Item -ItemType Directory -Path "C:\Logs" -Force | Out-Null
}
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$dateStamp = Get-Date -Format "yyyy-MM-dd"
$report = @()
$added = @()
$removed = @()
$hygiene = @()
$nestedGroups = @()
$domain = Get-ADDomain
$domainDN = $domain.DistinguishedName
# ---------- ENUMERATE GROUP MEMBERSHIP ----------
foreach ($groupName in $monitoredGroups) {
try {
$group = Get-ADGroup -Filter { Name -eq $groupName } -ErrorAction Stop | Select-Object -First 1
if ($null -eq $group) {
Add-Content -Path $logFile -Value "$timestamp — SKIP — group '$groupName' not found in domain"
continue
}
# Recursive pulls nested membership — this is the one people miss
$members = Get-ADGroupMember -Identity $group -Recursive -ErrorAction Stop
# Also grab direct members so we can spot nested GROUPS specifically
$directMembers = Get-ADGroupMember -Identity $group -ErrorAction SilentlyContinue
foreach ($direct in $directMembers) {
if ($direct.objectClass -eq "group") {
$nestedGroups += [PSCustomObject]@{
ParentGroup = $groupName
NestedGroup = $direct.Name
}
}
}
foreach ($member in $members) {
$lastLogon = "N/A"
$pwdLastSet = "N/A"
$enabled = "N/A"
$daysInactive = "N/A"
$pwdAgeDays = "N/A"
$flags = @()
if ($member.objectClass -eq "user") {
try {
$user = Get-ADUser -Identity $member.distinguishedName `
-Properties LastLogonDate, PasswordLastSet, Enabled, PasswordNeverExpires, ServicePrincipalName `
-ErrorAction Stop
$enabled = $user.Enabled
if ($user.LastLogonDate) {
$lastLogon = $user.LastLogonDate.ToString("yyyy-MM-dd")
$daysInactive = (New-TimeSpan -Start $user.LastLogonDate -End (Get-Date)).Days
if ($daysInactive -gt $staleAccountDays) {
$flags += "STALE ($daysInactive days)"
}
} else {
$lastLogon = "NEVER"
$flags += "NEVER LOGGED ON"
}
if ($user.PasswordLastSet) {
$pwdLastSet = $user.PasswordLastSet.ToString("yyyy-MM-dd")
$pwdAgeDays = (New-TimeSpan -Start $user.PasswordLastSet -End (Get-Date)).Days
if ($pwdAgeDays -gt $oldPasswordDays) {
$flags += "PASSWORD $pwdAgeDays DAYS OLD"
}
} else {
$pwdLastSet = "NEVER"
$flags += "PASSWORD NEVER SET"
}
if ($user.PasswordNeverExpires) {
$flags += "PASSWORD NEVER EXPIRES"
}
if ($user.ServicePrincipalName) {
$flags += "HAS SPN (kerberoastable)"
}
if ($user.Enabled -eq $false) {
$flags += "DISABLED BUT STILL A MEMBER"
}
} catch {
$flags += "LOOKUP ERROR"
}
}
elseif ($member.objectClass -eq "computer") {
$flags += "COMPUTER ACCOUNT IN PRIVILEGED GROUP"
}
$entry = [PSCustomObject]@{
Date = $dateStamp
GroupName = $groupName
MemberName = $member.Name
SamAccountName = $member.SamAccountName
ObjectClass = $member.objectClass
Enabled = $enabled
LastLogon = $lastLogon
DaysInactive = $daysInactive
PasswordSet = $pwdLastSet
PasswordAge = $pwdAgeDays
Flags = if ($flags.Count -gt 0) { $flags -join "; " } else { "OK" }
DistinguishedName = $member.distinguishedName
}
$report += $entry
if ($flags.Count -gt 0) { $hygiene += $entry }
}
Add-Content -Path $logFile -Value "$timestamp — $groupName — $($members.Count) effective member(s)"
} catch {
Add-Content -Path $logFile -Value "$timestamp — $groupName — ERROR: $_"
}
}
# ---------- COMPARE AGAINST BASELINE ----------
if (Test-Path $baselineFile) {
try {
$baseline = Import-Csv -Path $baselineFile
$currentKeys = $report | ForEach-Object { "$($_.GroupName)|$($_.DistinguishedName)" }
$baselineKeys = $baseline | ForEach-Object { "$($_.GroupName)|$($_.DistinguishedName)" }
foreach ($entry in $report) {
$key = "$($entry.GroupName)|$($entry.DistinguishedName)"
if ($baselineKeys -notcontains $key) { $added += $entry }
}
foreach ($entry in $baseline) {
$key = "$($entry.GroupName)|$($entry.DistinguishedName)"
if ($currentKeys -notcontains $key) { $removed += $entry }
}
} catch {
Add-Content -Path $logFile -Value "$timestamp — Baseline comparison failed: $_"
}
} else {
Add-Content -Path $logFile -Value "$timestamp — No baseline found. Creating one now."
}
# ---------- CORRELATE WITH SECURITY LOG ----------
# Event IDs: 4728/4729 global, 4732/4733 local, 4756/4757 universal
$auditEvents = @()
if ($added.Count -gt 0 -or $removed.Count -gt 0) {
try {
$pdc = $domain.PDCEmulator
$events = Get-WinEvent -ComputerName $pdc -FilterHashtable @{
LogName = "Security"
Id = 4728, 4729, 4732, 4733, 4756, 4757
StartTime = (Get-Date).AddDays(-2)
} -ErrorAction SilentlyContinue
foreach ($event in $events) {
$xml = [xml]$event.ToXml()
$targetGroup = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq "TargetUserName" }).'#text'
$subject = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq "SubjectUserName" }).'#text'
$memberDN = ($xml.Event.EventData.Data | Where-Object { $_.Name -eq "MemberName" }).'#text'
$auditEvents += [PSCustomObject]@{
Time = $event.TimeCreated.ToString("yyyy-MM-dd HH:mm:ss")
Action = if ($event.Id -in 4728, 4732, 4756) { "ADDED" } else { "REMOVED" }
Group = $targetGroup
Member = $memberDN
PerformedBy = $subject
}
}
} catch {
Add-Content -Path $logFile -Value "$timestamp — Security log correlation unavailable: $_"
}
}
# ---------- EXPORT ----------
$report | Sort-Object GroupName, MemberName | Export-Csv -Path $reportFile -NoTypeInformation
$report | Sort-Object GroupName, MemberName | Export-Csv -Path $baselineFile -NoTypeInformation
# ---------- BUILD EMAIL ----------
$emailBody = "Privileged Group Membership Report — $timestamp`n"
$emailBody += "Domain: $($domain.DNSRoot)`n"
$emailBody += "Groups monitored: $($monitoredGroups.Count)`n"
$emailBody += "Total privileged memberships: $($report.Count)`n`n"
$emailBody += "ADDED since last run: $($added.Count)`n"
$emailBody += "REMOVED since last run: $($removed.Count)`n"
$emailBody += "Hygiene issues: $($hygiene.Count)`n"
$emailBody += "Nested groups: $($nestedGroups.Count)`n`n"
if ($added.Count -gt 0) {
$emailBody += "*** NEW PRIVILEGED MEMBERS — VERIFY EACH ONE ***`n"
$emailBody += "=" * 60 + "`n"
foreach ($a in $added) {
$emailBody += "Group: $($a.GroupName)`n"
$emailBody += "Member: $($a.MemberName) ($($a.SamAccountName))`n"
$emailBody += "Type: $($a.ObjectClass)`n"
$emailBody += "Enabled: $($a.Enabled)`n"
$emailBody += "LastLogon: $($a.LastLogon)`n"
$emailBody += "Flags: $($a.Flags)`n"
$emailBody += "-" * 40 + "`n"
}
$emailBody += "`n"
}
if ($removed.Count -gt 0) {
$emailBody += "REMOVED FROM PRIVILEGED GROUPS:`n"
$emailBody += "=" * 60 + "`n"
foreach ($r in $removed) {
$emailBody += "$($r.GroupName) — $($r.MemberName) ($($r.SamAccountName))`n"
}
$emailBody += "`n"
}
if ($auditEvents.Count -gt 0) {
$emailBody += "WHO MADE THE CHANGE (from Security log, last 48h):`n"
$emailBody += "=" * 60 + "`n"
foreach ($e in $auditEvents) {
$emailBody += "$($e.Time) — $($e.Action) — $($e.Member) to/from $($e.Group) — by $($e.PerformedBy)`n"
}
$emailBody += "`n"
}
if ($nestedGroups.Count -gt 0) {
$emailBody += "NESTED GROUPS — MEMBERSHIP INHERITED INDIRECTLY:`n"
$emailBody += "=" * 60 + "`n"
foreach ($n in $nestedGroups) {
$emailBody += "$($n.NestedGroup) is a member of $($n.ParentGroup)`n"
}
$emailBody += "`n"
}
if ($hygiene.Count -gt 0) {
$emailBody += "HYGIENE ISSUES ON PRIVILEGED ACCOUNTS:`n"
$emailBody += "=" * 60 + "`n"
foreach ($h in ($hygiene | Sort-Object GroupName)) {
$emailBody += "$($h.GroupName) | $($h.MemberName) | $($h.Flags)`n"
}
$emailBody += "`n"
}
$emailBody += "CURRENT MEMBERSHIP BY GROUP:`n"
$emailBody += "=" * 60 + "`n"
foreach ($groupName in $monitoredGroups) {
$groupMembers = $report | Where-Object { $_.GroupName -eq $groupName }
if ($groupMembers.Count -gt 0) {
$emailBody += "`n$groupName ($($groupMembers.Count)):`n"
foreach ($m in $groupMembers) {
$emailBody += " - $($m.MemberName)`n"
}
}
}
if ($added.Count -eq 0 -and $removed.Count -eq 0) {
$emailBody += "`nNo membership changes since last run.`n"
}
$emailBody += "`nFull report: $reportFile`n"
$emailBody += "`nTo remove a member:`n"
$emailBody += "Remove-ADGroupMember -Identity 'GROUP' -Members 'USER' -Confirm:`$false`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"
$subject = if ($added.Count -gt 0) {
"PRIVILEGE ALERT: $($added.Count) new member(s) in privileged AD group(s)"
} elseif ($removed.Count -gt 0) {
"Privileged Group Change — $($removed.Count) member(s) removed"
} elseif ($hygiene.Count -gt 0) {
"Privileged Group Report — $($hygiene.Count) hygiene issue(s), no changes"
} else {
"Privileged Group Report — No changes, no issues"
}
Send-MailMessage `
-From $from -To $to `
-Subject $subject `
-Body $emailBody `
-SmtpServer $smtpServer -Port $smtpPort `
-UseSsl -Credential $credentialUpdate these lines:
$from/$to/$username/$password— your email details$smtpServer— your SMTP server (e.g.smtp.office365.com)$monitoredGroups— add your own tier-0 groups, see below$staleAccountDays/$oldPasswordDays— 90 and 365 are reasonable starting points
Requirements: Domain controller, or a machine with RSAT and the ActiveDirectory module. Read access to AD, plus Security log read on the PDC for the correlation section.
ADD YOUR OWN GROUPS — THE BUILT-INS AREN'T THE WHOLE STORY
The default list covers the well-known built-in groups. In most environments the more interesting privileges live in groups somebody created:
Server admin groups that are nested into local Administrators everywhere
The service account group your backup software runs under
Helpdesk groups with password reset rights over privileged OUs
Anything that can write to GPOs linked at the domain root
Groups with delegated rights over the OU containing your DCs
A helpdesk group that can reset the password on a Domain Admin account is functionally a Domain Admin group. It just doesn't look like one in the console.
Add them to $monitoredGroups by name.
RECURSIVE IS THE WHOLE POINT
The script uses -Recursive on Get-ADGroupMember, which resolves nested membership all the way down.
This matters more than it sounds. Open the Domain Admins properties in ADUC and you might see four entries. If one of those is a group containing a group containing twelve people, your actual Domain Admin count is fifteen, and the console will never tell you that.
Nested privilege is how most environments end up with far more effective admins than anyone believes. It's also a favourite persistence technique, because adding a compromised account to an innocuous-looking group three levels down doesn't show up in the place people check.
The NESTED GROUPS section of the email lists every group that's a direct member of a monitored group. If you see something there you don't recognise, follow it down:
powershell
Get-ADGroupMember -Identity "Suspicious Group" -Recursive | Select-Object Name, objectClassTHE HYGIENE FLAGS
The change diff is the security control. The hygiene flags are what you find on day one, and they're usually the bigger immediate win.
HAS SPN (kerberoastable) — read this one first. A privileged account with a Service Principal Name can have its Kerberos ticket requested by any authenticated user and cracked offline. If the password is weak, that's Domain Admin from a standard user account with no exploit required. A privileged account with an SPN and a password set in 2019 is a genuine emergency.
PASSWORD NEVER EXPIRES on a Domain Admin — very common, almost always a service account someone elevated because it was easier than working out the right delegation.
STALE / NEVER LOGGED ON — privileged accounts nobody uses. Break-glass accounts legitimately look like this, so don't blanket-remove. Everything else should go.
DISABLED BUT STILL A MEMBER — someone left, the account was disabled, membership was never cleaned up. Low risk while disabled, but it re-enables into full privilege the day somebody reactivates the account for a mailbox restore.
COMPUTER ACCOUNT IN PRIVILEGED GROUP — occasionally legitimate for clustering. Usually worth a conversation.
THE SECURITY LOG CORRELATION
When the diff finds a change, the script pulls events 4728/4729/4732/4733/4756/4757 from the PDC for the last 48 hours and tries to tell you who made it.
This turns the email from "something changed" into "svc_helpdesk added bjones to Domain Admins at 02:14." That's the difference between an alert and an investigation you can actually start.
Two caveats. Group membership changes can be written on any DC, and the script only reads the PDC — if the change was made elsewhere and hasn't replicated its Security log (it doesn't replicate), you'll get the diff without the attribution. And if Security log retention on your DCs is short or the log is busy, events can roll off before the next run.
If attribution matters to you and you don't already forward DC Security logs to a central collector, that's the real fix. The correlation here is a useful bonus, not a substitute.
SETTING UP TASK SCHEDULER
Press Windows key → type Task Scheduler → open it
Click "Create Basic Task" → name it
Privileged Group MonitorTrigger: Daily → 6:00am
Action: Start a program
Program:
powershell.exeArguments:
-ExecutionPolicy Bypass -File "C:\Scripts\PrivilegedGroupMonitor.ps1"Click Next → Finish
Under Properties → General, check "Run with highest privileges" and use an account with AD read plus Security log read on the PDC.
Daily is the minimum useful cadence. If you want faster detection, drop it to every 4 hours — the script is cheap to run and the baseline comparison doesn't care about interval. Just be aware that shorter intervals mean the 48-hour Security log window is doing less work for you.
TEST IT
Run on a DC as administrator:
powershell
powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\PrivilegedGroupMonitor.ps1"Open C:\Logs\PrivilegedGroupReport.csv and filter the Flags column to anything that isn't OK.
To verify change detection works, run it once to build the baseline, then add a test account to a low-risk monitored group — Print Operators is a good choice — and run again:
powershell
Add-ADGroupMember -Identity "Print Operators" -Members "testuser"
# run the script, confirm the alert
Remove-ADGroupMember -Identity "Print Operators" -Members "testuser" -Confirm:$falseYou should see it in ADDED on one run and REMOVED on the next.
THE FIRST RUN WILL BE UNCOMFORTABLE
Set expectations for yourself: the first report in an environment that's been running a few years is typically longer than people expect, and the Domain Admins count is usually higher than anyone would have guessed.
Don't try to clean it all up that afternoon. Removing privilege breaks things in ways that surface days later, and a half-finished cleanup is worse than a documented list.
A reasonable order:
Anything with an SPN — investigate today
Disabled accounts still holding membership — safe to remove
Stale accounts with no logon in a year — confirm they're not break-glass, then remove
Nested groups you can't explain — trace them before touching anything
Everything else — schedule a review with whoever owns the access
Document each removal with the date and reason. When something breaks in three weeks, that list is how you find the cause in five minutes instead of an afternoon.
WHY THIS MATTERS
Privileged group membership is the highest-signal thing in your entire domain. A change there is either a deliberate act by someone who should be doing it, or it's the most important alert you'll get all year. There is very little middle ground.
It's also one of the few controls where the detection is genuinely simple. No SIEM, no agent, no licensing — a scheduled script, a CSV baseline, and a diff. The reason it isn't universal isn't difficulty, it's that nobody sets it up.
The hygiene findings are the part that pays for itself immediately. Most people running this for the first time find at least one kerberoastable privileged account or one leaver still nested into admin rights.
THIS WEEK'S ACTION
Run it once today to build the baseline. Read only the HYGIENE section — ignore everything else for now. If anything in there says "HAS SPN," that's your task for this afternoon. Schedule it daily and let the diff start working from tomorrow.
Reply to this email if you hit any issues — I read every reply.
Automate & Operate — automateandoperate.com
1,000+ Proven ChatGPT Prompts That Help You Work 10X Faster
ChatGPT is insanely powerful.
But most people waste 90% of its potential by using it like Google.
These 1,000+ proven ChatGPT prompts fix that and help you work 10X faster.
Sign up for Superhuman AI and get:
1,000+ ready-to-use prompts to solve problems in minutes instead of hours—tested & used by 1M+ professionals
Superhuman AI newsletter (3 min daily) so you keep learning new AI tools & tutorials to stay ahead in your career—the prompts are just the beginning

