Skip to content

Instantly share code, notes, and snippets.

Created January 27, 2021 18:23
Show Gist options
  • Save jdhitsolutions/9255f0bf7fe0dc6d2dde868c18d5049f to your computer and use it in GitHub Desktop.
Save jdhitsolutions/9255f0bf7fe0dc6d2dde868c18d5049f to your computer and use it in GitHub Desktop.
A PowerShell script to create an HTML report on recent changes in Active Directory.
#requires -version 5.1
#requires -module ActiveDirectory,DNSClient
#Reporting on deleted items requires the Active Directory Recycle Bin feature
[Parameter(Position = 0,HelpMessage = "Enter a last modified datetime for AD objects. The default is the last 4 hours.")]
[datetime]$Since = ((Get-Date).AddHours(-4)),
[Parameter(HelpMessage = "What is the report title?")]
[string]$ReportTitle = "Active Directory Change Report",
[Parameter(HelpMessage = "Specify the path to an image file to use as a logo in the report.")]
[ValidateScript({Test-Path $_})]
[Parameter(HelpMessage = "Add a second grouping based on the object's container or OU.")]
[Parameter(HelpMessage = "Specify the path for the output file.")]
[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.")]
#region helper functions
#a private helper function to convert the objects to html fragments
Function _convertObjects {
#convert each table to an XML fragment so I can insert a class attribute
[xml]$frag = $objects | Sort-Object -property WhenChanged |
Select-Object -Property DistinguishedName,Name,WhenCreated,WhenChanged,IsDeleted |
ConvertTo-Html -Fragment
for ($i = 1; $i -lt $;$i++) {
if (($[$i].td[2] -as [datetime]) -ge $since) {
#highlight new objects in green
$class = $frag.CreateAttribute("class")
} #if new
#insert the alert attribute if the object has been deleted.
if ($[$i].td[-1] -eq 'True') {
#highlight deleted objects in red
$class = $frag.CreateAttribute("class")
} #if deleted
} #for
#write the innerXML (ie HTML code) as the function output
# private helper function to insert javascript code into my html
function _insertToggle {
#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) {
else {
$out.Add($($Data | ConvertTo-Html -Fragment))
#some report metadata
$reportVersion = "2.3.3"
$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()
if ($Logo) {
#need to use full path
$imagefile = Convert-Path -path $logo
Write-Verbose "[$(Get-Date)] Using logo file $imagefile"
#encode the graphic file to embed into the HTML
$ImageBits = [Convert]::ToBase64String((Get-Content $imagefile -Encoding Byte))
$ImageHTML = "<img alt='logo' class='center' src=data:image/png;base64,$($ImageBits)/>"
$top = @"
<table class='header'>
else {
$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 $[0].ToString().toUpper(),$
Write-Verbose "[$(Get-Date)] Processing $category [$($item.count)]"
if ($ByContainer) {
Write-Verbose "[$(Get-Date)] Organizing by container"
$subgroup = $ | 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)] $($"
$fragGroup = _convertObjects $
$divid = $ -replace "=|,",""
$fraghtml.Add($(_inserttoggle -Text "$($ [$($subitem.count)]" -div $divid -Heading "H4" -Data $fragGroup -NoConvert))
} #foreach subitem
} #if by container
else {
Write-Verbose "[$(Get-Date)] Organizing by distinguishedname"
$fragHtml = _convertObjects $
$code = _insertToggle -Text "$category [$($item.count)]" -div $category -Heading "H3" -Data $fragHtml -NoConvert
} #foreach item
#my embedded CSS
$head = @"
h2 {
color: #fffc35;
h4 {
body {
td, th {
border:1px solid black;
th {
table, tr, td, th {
padding-left: 10px;
margin: 0px
tr:nth-child(odd) {background-color: lightgray}
table {
.alert { color:red; }
.new { color:green; }
table.footer tr,
table.footer td {
background-color: white;
border-collapse: collapse;
border: none;
table.footer {
width: 25%;
padding-left: 10px;
margin-left: 70%;
font-size: 10pt;
cellpadding: 0;
td.size {
text-align: right;
padding-right: 25px;
.center {
display: block;
margin-left: auto;
margin-right: auto;
width: 50%;
table.header tr,
table.header td {
border-collapse: collapse;
border: none;
<script type='text/javascript' src=''>
<script type='text/javascript'>
function toggleDiv(divId) {
function toggleAll() {
var divs = document.getElementsByTagName('div');
for (var i = 0; i < divs.length; i++) {
var div = divs[i];
#who is running the report?
if ($Credential) {
$who = $Credential.UserName
else {
$who = "$($env:USERDOMAIN)\$($env:USERNAME)"
#where are they running the report from?
Try {
#disable verbose output from Resolve-DNSName
$where = (Resolve-DnsName -Name $env:COMPUTERNAME -Type A -ErrorAction Stop -verbose:$False).Name | Select-Object -last 1
Catch {
$where = $env:COMPUTERNAME
#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>
<tr align = "right"><td>Author: <i>$($Who.toUpper())</i></td></tr>
<tr align = "right"><td>Computername: <i>$($where.toUpper())</i></td></tr>
#text to display in the report
$content = @"
Active Directory changes since $since as reported from domain controller $($Server.toUpper()). Replication-only changes may be included in this report.
You will need to view event logs for more detail about these changes, including who made the change.
$htmlParams = @{
Head = $head
precontent = $content
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)"
Copy link

An earlier version of this script was published and described at

Copy link

Here's an example using a logo file. The object types are collapsible.


Copy link

Dr99JJ commented Feb 23, 2024

Fantastic script. Thanks!
Only thing having trouble with is getting my .png logo working. Have tried local paths, full path, ./in

, URL, just cant seem to get it. Maybe my png image is the issue. If you can show a sample snippet of what your logo string/path look like? maybe i will switch image types -- much appreciate

Copy link

You should be able to specify a local path:

.\adchangereport.ps1 -logo c:\work\logo.png

You might try running the script with -Verbose to see if there is any other information. Do you get an error or just no logo in the report?

Copy link

Dr99JJ commented Feb 24, 2024

Just no logo, I will try that and make sure my logo image is valid - much appreciated

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment