An Active Directory Change Report from PowerShell

A few days ago I posted some PowerShell code that you could use to be alerted when things changed in Active Directory. The code used PowerShell and CIM events to notify you, for example, when a new user account is created. This can be helpful when you need alerting. But perhaps you only need reporting. What has changed in Active Directory since a given date and time, such as in the last 24 hours? And wouldn’t it be nice to have a pretty report? Let me help. Here’s how I approached the prob lem using PowerShell and the ActiveDirectory module.

WhenChanged

Objects in Active Directory have a WhenChanged property. Many of the ActiveDirectory commands have a filter parameter. This parameter is very flexible and you definitely want to read command help for it. The reason for using the filter is that objects are filtered at the source which is much faster. This would NOT be the recommended way to filter:

$since = (Get-Date).AddMinutes(-90)
Get-ADuser -filter * -properties WhenChanged | Where { $_.WhenChanged -ge $since }

It will work, but think about it. You are telling PowerShell, “Get me all user objects from the domain, send them to my computer, and then filter where the WhenChanged property is greater than the last 90 minutes.” In a small domain, you won’t notice. But if you have large domain, there is a definite difference.

Instead, create a filter like this:

Get-ADuser -filter {WhenChanged -ge $since} -Properties WhenChanged |
 Select-Object DistinguishedName,Name,WhenChanged

The tricky part is that you need to create this particular filter as a scriptblock. I haven’t done any AD scripting in a while and it took some time to get this worked out. I re-read cmdlet help and that got me headed in the right direction. Always read the help.

Here are my results. Of course, I have no idea what changed but I’m not concerned about that. One thing to keep in mind, is that you may have changed objects where the only change is due to replication and a USN update. I haven’t found a good way to filter out those types of changes.

Get-ADObject

For my report, I only care about users, groups, computers and OUs. While I could use the specific Get-* cmdlet, it is probably just as easy to use Get-ADObject and create a good filter.

$filter = {(objectclass -eq 'user' -or objectclass -eq 'group' -or objectclass -eq 'organizationalunit' ) -AND (WhenChanged -gt $since )}

Here’s one way I might use this filter.

Get-ADObject -Filter $filter -Properties WhenCreated,WhenChanged | 
 Sort-Object -Property ObjectClass,WhenChanged |
 Format-Table -GroupBy objectClass -Property DistinguishedName,WhenCreated,WhenChanged

One thing to point out is that if the WhenCreated is greater or equal to my cutoff date, that is most likely a new object.

Another reason for using Get-ADObject is that it will also show deleted objects. However, you need to enable the Active Directory Recycle Bin. But if you have, here’s revised code:

Get-ADObject -Filter $filter -Properties WhenCreated,WhenChanged -IncludeDeletedObjects | 
 Select-Object *,@{Name="IsNew";Expression = { if ($_.WhenCreated -ge $since) { $True} else {$False}}},
 @{Name="IsDeleted";Expression = {$_.distinguishedname -match "Deleted Objects" }} |
 Sort-Object -Property ObjectClass,WhenChanged |
 Format-List -GroupBy objectClass -Property DistinguishedName,WhenCreated,WhenChanged,IsNew,IsDeleted

I’m defining some additional properties to indicate new or deleted objects.

There is a property called IsDeleted which you can also use, although it will only exist on deleted objects. My code defines it for all objects.

Once you have the objects and properties there’s no end to how you can format or use the results.

Creating an HTML Report

I love creating reports and my favorite tool is ConvertTo-Html. In my reporting scripts, I tend to create stand-alone files with embedded style. This is a good approach if you plan on emailing files. My script is really a control script that wraps around the AD, ConvertTo-HTML, and Out-File cmdlets. I’m not writing a function to send objects to the pipeline. I’m running an orchestrated set of PowerShell commands to produce a desired result. This is a control script.

These types of scripts can also have parameters. Very often, I’m passing the script parameters to the underlying cmdlets, often using PSBoundParameters and splatting. Although lately I’ve been using a private version $PSDefaultParameterValues, which is what I’m using in this script. Which I might as well show you now.

#requires -module ActiveDirectory

#Reporting on deleted items requires the Active Directory Recycle Bin feature
[cmdletbinding()]
Param(
    [Parameter(Position = 0,HelpMessage = "Enter a last modified datetime for AD objects. The default is the last 8 hours.")]
    [ValidateNotNullOrEmpty()]
    [datetime]$Since = ((Get-Date).AddHours(-8)),
    [Parameter(HelpMessage = "What is the report title?")]
    [string]$ReportTitle = "Active Directory Change Report",
    [Parameter(HelpMessage = "Add a second grouping based on the object's container or OU.")]
    [switch]$ByContainer,
    [Parameter(HelpMessage = "Specify the path for the output file.")]
    [ValidateNotNullOrEmpty()]
    [string]$Path = ".\ADChangeReport.html",
    [Parameter(HelpMessage = "Specifies the Active Directory Domain Services domain controller to query. The default is your Logon server.")]
    [string]$Server = $env:LOGONSERVER.SubString(2),
    [Parameter(HelpMessage = "Specify an alternate credential for authentication.")]
    [pscredential]$Credential,
    [ValidateSet("Negotiate","Basic")]
    [string]$AuthType
)

#region helper functions

#a private helper function to convert the objects to html fragments
Function _convertObjects {
    Param([object[]]$Objects)
    #convert each table to an XML fragment so I can insert a class attribute
    $frag = $objects | Sort-Object -property WhenChanged |
    Select-Object -Property DistinguishedName,Name,WhenCreated,WhenChanged,IsDeleted |
    ConvertTo-Html -Fragment

    for ($i = 1; $i -lt $frag.table.tr.count;$i++) {
        if (($frag.table.tr[$i].td[2] -as [datetime]) -ge $since) {
            #highlight new objects in green
            $class = $frag.CreateAttribute("class")
            $class.value="new"
            [void]$frag.table.tr[$i].Attributes.append($class)
        } #if new

        #insert the alert attribute if the object has been deleted.
        if ($frag.table.tr[$i].td[-1] -eq 'True') {
            #highlight deleted objects in red
            $class = $frag.CreateAttribute("class")
            $class.value="alert"
            [void]$frag.table.tr[$i].Attributes.append($class)
        } #if deleted
    } #for

    #write the innerXML (ie HTML code) as the function output
    $frag.InnerXml
}

# private helper function to insert javascript code into my html
function _insertToggle {
    [cmdletbinding()]
    #The text to display, the name of the div, the data to collapse, and the heading style
    #the div Id needs to be simple text
    Param([string]$Text, [string]$div, [object[]]$Data, [string]$Heading = "H2", [switch]$NoConvert)

    $out = [System.Collections.Generic.list[string]]::New()
    if (-Not $div) {
        $div = $Text.Replace(" ", "_")
    }
    $out.add("<a href='javascript:toggleDiv(""$div"");' title='click to collapse or expand this section'><$Heading>$Text</$Heading></a><div id=""$div"">")
    if ($NoConvert) {
        $out.Add($Data)
    }
    else {
        $out.Add($($Data | ConvertTo-Html -Fragment))
    }
    $out.Add("</div>")
    $out
}

#endregion

#some report metadata
$reportVersion = "2.1.1"
$thisScript = Convert-Path $myinvocation.InvocationName

Write-Verbose "[$(Get-Date)] Starting $($myinvocation.MyCommand)"
Write-Verbose "[$(Get-Date)] Detected these bound parameters"
$PSBoundParameters | Out-String | Write-Verbose

#set some default parameter values
$params = "Credential","AuthType"
$script:PSDefaultParameterValues = @{"Get-AD*:Server" = $Server}
ForEach ($param in $params) {
    if ($PSBoundParameters.ContainsKey($param)) {
        Write-Verbose "[$(Get-Date)] Adding 'Get-AD*:$param' to script PSDefaultParameterValues"
        $script:PSDefaultParameterValues["Get-AD*:$param"] = $PSBoundParameters.Item($param)
    }
}

Write-Verbose "[$(Get-Date)] Getting current Active Directory domain"
$domain = Get-ADDomain

#create a list object to hold all of the HTML fragments
Write-Verbose "[$(Get-Date)] Initializing fragment list"
$fragments = [System.Collections.Generic.list[string]]::New()
$fragments.Add("<H2>$($domain.dnsroot)</H2>")
$fragments.Add("<a href='javascript:toggleAll();' title='Click to toggle all sections'>+/-</a>")

Write-Verbose "[$(Get-Date)] Querying $($domain.dnsroot)"
$filter = {(objectclass -eq 'user' -or objectclass -eq 'group' -or objectclass -eq 'organizationalunit' ) -AND (WhenChanged -gt $since )}

Write-Verbose "[$(Get-Date)] Filtering for changed objects since $since"
$items = Get-ADObject -filter $filter -IncludeDeletedObjects -Properties WhenCreated,WhenChanged,IsDeleted -OutVariable all | Group-Object -property objectclass

Write-Verbose "[$(Get-Date)] Found $($all.count) total items"

if ($items.count -gt 0) {
    foreach ($item in $items) {
        $category = "{0}{1}" -f $item.name[0].ToString().toUpper(),$item.name.Substring(1)
        Write-Verbose "[$(Get-Date)] Processing $category [$($item.count)]"

        if ($ByContainer) {
            Write-Verbose "[$(Get-Date)] Organizing by container"
            $subgroup = $item.group | Group-Object -Property { $_.distinguishedname.split(',', 2)[1] } | Sort-Object -Property Name
            $fraghtml = [System.Collections.Generic.list[string]]::new()
            foreach ($subitem in $subgroup) {
                Write-Verbose "[$(Get-Date)] $($subItem.name)"
                $fragGroup = _convertObjects $subitem.group
                $divid = $subitem.name -replace "=|,",""
                $fraghtml.Add($(_inserttoggle -Text "$($subItem.name) [$($subitem.count)]" -div $divid -Heading "H4" -Data $fragGroup -NoConvert))
            } #foreach subitem
        } #if by container
        else {
            $fragHtml = _convertObjects $item.group
        }
         $code = _insertToggle -Text "$category [$($item.count)]" -div $category -Heading "H3" -Data $fragHtml -NoConvert
        $fragments.Add($code)
    } #foreach item

#my embedded CSS
    $head = @"
<Title>$ReportTitle</Title>
<style>
h2 {
    width:95%;
    background-color:#7BA7C7;
    font-family:Tahoma;
    font-size:12pt;
}
h4 {
    width:95%;
    background-color:#b5f144;
}
body {
    background-color:#FFFFFF;
    font-family:Tahoma;
    font-size:12pt;
}
td, th {
    border:1px solid black;
    border-collapse:collapse;
}
th {
    color:white;
    background-color:black;
}
table, tr, td, th {
    padding-left: 10px;
    margin: 0px
}
tr:nth-child(odd) {background-color: lightgray}
table {
    width:95%;
    margin-left:5px;
    margin-bottom:20px;
}
.alert { color:red; }
.new { color:green; }
.footer { font-size:10pt; }
.footer tr:nth-child(odd) {background-color: white}
.footer td,tr {
    border-collapse:collapse;
    border:none;
}
.footer table {width:15%;}
td.size {
    text-align: right;
    padding-right: 25px;
}
</style>
<script type='text/javascript' src='https://ajax.googleapis.com/ajax/libs/jquery/1.4.4/jquery.min.js'>
</script>
<script type='text/javascript'>
function toggleDiv(divId) {
`$("#"+divId).toggle();
}
function toggleAll() {
var divs = document.getElementsByTagName('div');
for (var i = 0; i < divs.length; i++) {
var div = divs[i];
`$("#"+div.id).toggle();
}
}
</script>
<H1>$ReportTitle</H1>
"@

#a footer for the report. This could be styled with CSS
    $post = @"
<table class='footer'>
    <tr align = "right"><td>Report run: <i>$(Get-Date)</i></td></tr>
    <tr align = "right"><td>Report version: <i>$ReportVersion</i></td></tr>
    <tr align = "right"><td>Source: <i>$thisScript</i></td></tr>
</table>
"@

    $htmlParams = @{
        Head = $head
        precontent = "Active Directory changes since $since. Reported from $($Server.toUpper()). Replication only changes may be included."
        Body =($fragments | Out-String)
        PostContent = $post
    }
    Write-Verbose "[$(Get-Date)] Creating report $ReportTitle version $reportversion saved to $path"
    ConvertTo-HTML @htmlParams | Out-File -FilePath $Path
    Get-Item -Path $Path
}
else {
    Write-Warning "No modified objects found in the $($domain.dnsroot) domain since $since."
}

Write-Verbose "[$(Get-Date)] Ending $($myinvocation.MyCommand)"

The script creates a series of HTML fragments which are eventually pulled together to create the final file. My script includes a few helper functions. One of them parses the HTML as XML so that I can insert a class attribute to indicate if the object is new or deleted. I have a defined style that shows deleted objects in red and new objects in green. The other function helps me insert javascript that lets me collapse sections of the file.

c:\scripts\ADChangeReport.ps1 -Since (Get-Date).Addhours(-1) -Path c:\scripts\ad6.html

The script will default to the logon server for the current user, although you can specify a different domain controller. You can also specify alternate credentials. You can, and should, run the script from a domain member desktop.

I can click on the +/- to toggle collapsing all regions, or click on the different categories. You can see the new group in green text.

In the bottom part of the report you can see the deleted user in red. I also like to include metadata information in the footer of my reports. This information helps me know where the report originated. This can be helpful if you setup a scheduled reporting system but forget to document it. One missing piece of information in this report is the computer name. I can see the path for the script file but I can’t tell what computer. I’ll leave that adjustment to you.

The reporting script also supports organizing the results by container. My code will group on the object’s container which I get by splitting the distinguished name.

if ($ByContainer) {
  Write-Verbose "[$(Get-Date)] Organizing by container"
  $subgroup = $item.group | Group-Object -Property { $_.distinguishedname.split(',', 2)[1] } | Sort-Object -Property Name
...

Summary

This is an admittedly complicated PowerShell script so if there is anything you don’t understand and have questions about, please feel free to ask in the comments. I will also point out that like the AD event monitor, just because you can use something like this doesn’t mean you should. Small domains with minimal changes could get by with this script or something similar.

I’d be leery though, of using this in a large enterprise with a highly dynamic Active Directory infrastructure. Although if your change window was small, it might be effective. I’m assuming that if you are running a large AD environment, your company has invested in high-quality and appropriate management and reporting tools. You can leverage PowerShell for those niche or special situations.

If you can try out the reporting script, I’d love to hear your experiences. Enjoy!

About the Author:

I am long-time Microsoft professional, a multi-year Microsoft MVP in Windows PowerShell (now Cloud and DataCenter Management) and an IT veteran with almost 30 years of experience, much of it spent as an IT consultant specializing in Windows server technologies. I work today as an independent author, teacher, and consultant.  I’ve co-authored or authored several books, courseware, and training videos on administrative scripting and automation. These days my focus is on PowerShell, automation and DevOps. You can check out my latest books and training videos here.

Reference:

Hicks, J. (2021). An Active Directory Change Report from PowerShell. Available at: https://jdhitsolutions.com/blog/powershell/8087/an-active-directory-change-report-from-powershell/ [Accessed: 19th February 2021].

Share this on...

Rate this Post:

Share:

Topics:

PowerShell