Skip to content

Instantly share code, notes, and snippets.

@jdhitsolutions
Created January 27, 2021 18:23
Show Gist options
  • Select an option

  • Save jdhitsolutions/9255f0bf7fe0dc6d2dde868c18d5049f to your computer and use it in GitHub Desktop.

Select an option

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
# https://jdhitsolutions.com/blog/powershell/8087/an-active-directory-change-report-from-powershell/
#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 4 hours.")]
[ValidateNotNullOrEmpty()]
[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 $_})]
[string]$Logo,
[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
[xml]$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.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'>
<tr>
<td>$imageHTML</td>
<td><H1>$ReportTitle</H1></td>
</tr>
</table>
"@
$fragments.Add($top)
}
else {
$fragments.Add("<H1>$ReportTitle</H1>")
}
$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 {
Write-Verbose "[$(Get-Date)] Organizing by distinguishedname"
$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;
color: #fffc35;
font-size:16pt;
}
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; }
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 {
background-color:white;
border-collapse: collapse;
border: none;
}
</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>
"@
#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>
</table>
"@
#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)"
@jdhitsolutions
Copy link
Author

An earlier version of this script was published and described at https://jdhitsolutions.com/blog/powershell/8087/an-active-directory-change-report-from-powershell/.

@jdhitsolutions
Copy link
Author

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

image

@Dr99JJ
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

@jdhitsolutions
Copy link
Author

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?

@Dr99JJ
Copy link

Dr99JJ commented Feb 24, 2024

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

@mbanthi
Copy link

mbanthi commented Aug 13, 2025

Hi, this report is incredible. Thank you very much for sharing. However, I'm wondering if you could help me by telling me how I can include these fields in the user section:
SamAccountName, Enabled, LastLogonDate

Regards

@jdhitsolutions
Copy link
Author

Adding extra fields for the user would require refactoring the script. You would need to specify the properties here:

$items = Get-ADObject -filter $filter -IncludeDeletedObjects -Properties WhenCreated,WhenChanged,IsDeleted -OutVariable all | Group-Object -property objectclass

Then you would need to filter the items and only show the user properties for user objects. Another option would be to make a copy of the script and modify the filter to only show user objects.

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