Skip to content

Instantly share code, notes, and snippets.

@jdhitsolutions
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
# 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)"
@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

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