ADChangeReport.ps1

#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"

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;
}
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)"