In partnership with

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.ps1

powershell

# 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 $credential

Update 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, objectClass

THE 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

  1. Press Windows key → type Task Scheduler → open it

  2. Click "Create Basic Task" → name it Privileged Group Monitor

  3. Trigger: Daily → 6:00am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

-ExecutionPolicy Bypass -File "C:\Scripts\PrivilegedGroupMonitor.ps1"
  1. 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:$false

You 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:

  1. Anything with an SPN — investigate today

  2. Disabled accounts still holding membership — safe to remove

  3. Stale accounts with no logon in a year — confirm they're not break-glass, then remove

  4. Nested groups you can't explain — trace them before touching anything

  5. 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