Report on changed Active Directory groups using PowerShell

Currently, I’m working for a customer on a new security model for their Active Directory. We discussed how to report on changes in certain administrative groups. I did this in the past using the Active Directory command-line tools (dsquery, dsget, etc.), but in this blog post, I will show you how to do this using PowerShell.

Requirements

The script should show the member difference between the previous time it ran and the current situation, logs should be saved for reviewing purposes, and an email should be sent when a change is detected.

Running the script

The best way would be to add this as a scheduled task on an admin server or Domain Controller with the Active Directory module for PowerShell installed. The script can be run with “powershell.exe” as Program/Script, c:\scripts\AdminGroupChangeReport.ps1 as an argument, and c:\scripts as Start in. Configure a trigger with an interval of an hour and course, use an account to run the script with enough credentials 🙂

Below is the output of the script when no change is detected. It shows the groups being checked and the members in them:

If it does find an Admin group that has been changed (Member added/removed), it will send an email with the subject of ‘Admin group changed detected’ with the following body: (Options are configurable in the script)

The logs from the current and previous runs are stored in the Logs folder that you have specified:

The time-stamped changes.txt contains the changes found, the previousmembers.csv is the one from the previous run, and the currentmembers.csv and previousmembers.csv are the files from the current run.

The script

Below is the script. Ensure the $logs variable in the script is configured to the path you store the script in, and configure the email options to the ones suited for your environment. The admingroups variable consists of all the groups that you want to monitor. These are just for example, and add your own and use the Display Name value.

#Set Logs folder
$logs = 'c:\scripts\logs'

#Create Logs folder it it doesn't exist
if (-not (Test-Path -Path $logs -PathType Any)) {
    New-Item -Path $logs -ItemType Directory | Out-Null
}

#Start Transcript logging to $logs\run.log
Start-Transcript -Path "$($logs)\run.log" -Append

#Configure groups to monitor
$admingroups = @(
    "Account Operators",
    "Administrators",
    "Backup Operators",
    "Domain Admins",
    "DNSAdmins",
    "Enterprise Admins",
    "Group Policy Creator Owners",
    "Schema Admins",
    "Server Operators"
)

#rename previous currentmembers.csv to previousmembers.csv and rename the old
#previousmembers.csv to one with a time-stamp for archiving
if (Test-Path -Path "$($logs)\previousmembers.csv" -ErrorAction SilentlyContinue) {
    #Set date format variable
    $date = Get-Date -Format 'dd-MM-yyyy-HHMM'
    Write-Host ("- Renaming previousmembers.csv to {0}_previousmembers.csv" -f $date) -ForegroundColor Green
    Move-Item -Path "$($logs)\previousmembers.csv" -Destination "$($logs)\$($date)_previousmembers.csv" -Confirm:$false -Force:$true
}

if (Test-Path -Path "$($logs)\currentmembers.csv" -ErrorAction SilentlyContinue) {
    Write-Host ("- Renaming currentmembers.csv to previousmembers.csv") -ForegroundColor Green
    Move-Item -Path "$($logs)\currentmembers.csv" -Destination "$($logs)\previousmembers.csv" -Confirm:$false -Force:$true
}

#Retrieve all direct members of the admingroups,
#store them in the members variable and output
#them to currentmembers.csv
$members = foreach ($admingroup in $admingroups) {
    Write-Host ("- Checking {0}" -f $admingroup) -ForegroundColor Green
    try {
        $admingroupmembers = Get-ADGroupMember -Identity $admingroup -Recursive -ErrorAction Stop | Sort-Object SamAccountName
    }
    catch {
        Write-Warning ("Members of {0} can't be retrieved, skipping..." -f $admingroup)
        $admingroupmembers = $null
    }
    if ($null -ne $admingroupmembers) {
        foreach ($admingroupmember in $admingroupmembers) {
            Write-Host ("  - Adding {0} to list" -f $admingroupmember.SamAccountName) -ForegroundColor Green
            [PSCustomObject]@{
                Group  = $admingroup
                Member = $admingroupmember.SamAccountName
            }
        }
    }
}

#Save found members to currentmembers.csv and create previousmembers.csv if not present (First Run)
Write-Host ("- Exporting results to currentmembers.csv") -ForegroundColor Green
$members | export-csv -Path "$($logs)\currentmembers.csv" -NoTypeInformation -Encoding UTF8 -Delimiter ';'
if (-not (Test-Path "$($logs)\previousmembers.csv")) {
    $members | export-csv -Path "$($logs)\previousmembers.csv" -NoTypeInformation -Encoding UTF8 -Delimiter ';'
}

#Compare currentmembers.csv to the #previousmembers.csv
$CurrentMembers = Import-Csv -Path "$($logs)\currentmembers.csv" -Delimiter ';'
$PreviousMembers = Import-Csv -Path "$($logs)\previousmembers.csv" -Delimiter ';'
Write-Host ("- Comparing current members to the previous members") -ForegroundColor Green
$compare = Compare-Object -ReferenceObject $PreviousMembers -DifferenceObject $CurrentMembers -Property Group, Member
if ($null -ne $compare) {
    $differencetotal = foreach ($change in $compare) {
        if ($change.SideIndicator -match ">") {
            $action = 'Added'
        }
        if ($change.SideIndicator -match "<") {
            $action = 'Removed'
        }

        [PSCustomObject]@{
            Date   = $date
            Group  = $change.Group
            Action = $action
            Member = $change.Member
        }
    }

    #Save output to file
    $differencetotal | Sort-Object group | Out-File "$($logs)\$($date)_changes.txt"

    #Send email with changes to admin email address
    Write-Host ("- Emailing detected changes") -ForegroundColor Green
    $body = Get-Content "$($logs)\$($date)_changes.txt" | Out-String
    $options = @{
        Body        = $body
        Erroraction = 'Stop'
        From        = 'admin@powershellisfun.com'
        Priority    = 'High'
        Subject     = "Admin group change detected"
        SmtpServer  = 'emailserver.domain.local'
        To          = 'harm@powershellisfun.com'     
    }
    
    try {
        Send-MailMessage @options
    }
    catch {
        Write-Warning ("- Error sending email, please check the email options")
    }
}
else {
    Write-Host ("No changes detected") -ForegroundColor Green
}

Stop-Transcript

Download the script(s) from GitHub here

11 thoughts on “Report on changed Active Directory groups using PowerShell

  1. I like the script and have used something similar in the past for general reporting purposes. The issue is it doesn’t tell you the “who” or “when” of the change. When I want to know the details, I prefer to use Get-WinEvent or Get-EventLog commandlets. It takes a bit of effort in large environments, but if the script(s) are part of your DC build process, it’s fairly easy to manage. Just my two cents :-).

  2. You’re right, this only gives you the something changed but not the who changed it report 🙂 But good idea, will write a different blogpost on Account management events and reporting on that!

  3. Nice work! .csv output also means it can be normalized in a SIEM (Splunk, QRADAR, AlienVault) where you could correlate the added users. Taken a step further and paired with a SOAR you could actually revert the changes back automatically and sus out who did it later.

  4. Pingback: Retrieve Security events from Active Directory using PowerShell | PowerShell is fun :)

  5. Nice stuff, @Harm!
    Can also check out my Get-ADGroupChanges script, “pure” powershell cmdlet (no dependencies, no special permissions needed etc’) to retrieve change history in an AD group membership, or all groups, or per user. relies on object metadata rather than event logs. useful for DF/IR, tracking changes in groups etc’. Supports querying AD Metadata either from an Online Domain Controller, or from an offline system state backup / Snapshot:
    https://github.com/YossiSassi/Get-ADGroupChanges

  6. Pingback: PowerShell is fun :) Overview of 2022 posts

  7. Hi Harm,

    You’ve built an array with Admin Groups. Fine, … when you’re workink with an english culture.
    There is a .Net Class called [System.Security.Principal.WellKnownSidType]. This is an enum
    [Enum]::GetValues([System.Security.Principal.WellKnownSidType]) ==> Return names in the local culture for all Well-Known SID.

    $Sid = [System.Security.Principal.WellKnownSidType]::WorldSid
    $SecIdentifier = New-Object System.Security.Principal.SecurityIdentifier($Sid, $null)
    $SecIdentifier.Translate([System.Security.Principal.NTAccount]).Value
    In my culture, this returns “Tout le monde”

    Or in one-liner : (New-Object System.Security.Principal.SecurityIdentifier([System.Security.Principal.WellKnownSidType]::WorldSid, $null)).Translate([System.Security.Principal.NTAccount]).Value

    Take care about
    $Sid = [System.Security.Principal.WellKnownSidType]::AccountDomainAdminsSid
    $SecIdentifier = New-Object System.Security.Principal.SecurityIdentifier($Sid, $null)
    $SecIdentifier.Translate([System.Security.Principal.NTAccount]).Value
    If you’re not in a domain, this return an error.

    By this way, your code will run fine in every culture.

    The Well-known SID list is here :https://morgantechspace.com/2013/10/well-known-sids-and-built-in-groups.html
    and abour Well-known SID Enum : https://learn.microsoft.com/en-us/dotnet/api/system.security.principal.wellknownsidtype?view=netframework-4.8.1#system-security-principal-wellknownsidtype-accountdomainadminssid

    I’m sure there is another way more “friendly user” to do this , I bang my head against the walls this morning, no way to remember.

    • Sorry for not responding earlier, your message was in the Junk mail (Perhaps because of the links) And nice addition, I always forget other languages 🙁 Thanks for the feedback and I will put it on the to-do!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.