functions/ADChangeReport.ps1
# An earlier version was first described at https://jdhitsolutions.com/blog/powershell/8087/an-active-directory-change-report-from-powershell/ #Reporting on deleted items requires the Active Directory Recycle Bin feature Function New-ADChangeReport { [cmdletbinding()] [outputtype("System.IO.FileInfo")] 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 = "Specify the path to the CSS file. If you don't specify one, the default module file will be used.")] [ValidateScript( { Test-Path $_ })] [string]$CSSUri = "$PSScriptRoot\..\reports\changereport.css", [Parameter(HelpMessage = "Embed the CSS file into the HTML document head. You can only embed from a file, not a URL.")] [switch]$EmbedCSS, [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.")] [ValidateNotNullOrEmpty()] [string]$Server = $env:LOGONSERVER.Substring(2), [Parameter(HelpMessage = "Specify an alternate credential for authentication.")] [pscredential]$Credential, [ValidateSet("Negotiate", "Basic")] [string]$AuthType ) Begin { Write-Verbose "[$(Get-Date)] Starting $($myinvocation.MyCommand)" #some report metadata $reportVersion = (Get-Module ADReportingTools).Version.toString() $thisScript = $($myinvocation.MyCommand) Write-Verbose "[$(Get-Date)] Detected these bound parameters" $PSBoundParameters | Out-String | Write-Verbose #set some default parameter values $params = "Credential", "AuthType","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) } } #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 } #What Domain controller was queried? $dc = (Resolve-DnsName -Name $server -Type A | Select-Object -first 1).Name.ToUpper() #text to display in the report $content = @" <br/>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. "@ #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> <tr align = "right"><td>DomainController: <i>$dc</i></td></tr> </table> "@ #my default head $head = @" <Title>$ReportTitle</Title> <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> "@ $htmlParams = @{ Head = $head Precontent = $content Body = "" PostContent = $post } If ($EmbedCSS) { if (Test-Path -Path $CSSUri) { Write-Verbose "[$(Get-Date)] Embedding CSS content from $CSSUri" $cssContent = Get-Content -Path $CSSUri | Where-Object { $_ -notmatch "^@" } $head += @" <style> $cssContent </style> "@ } else { Write-Error "Failed to find a CSS file at $CSSUri. You can only embed from a file." #bail out Write-Verbose "[$(Get-Date)] Ending $($myinvocation.mycommand)" return } } else { Write-Verbose "[$(Get-Date)] Adding CSSPath $CSSUri" $htmlParams.Add("CSSUri", $CSSUri) } #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("<a href='javascript:toggleAll();' title='Click to toggle all sections'>+|-</a>") Write-Verbose "[$(Get-Date)] Getting current Active Directory domain" $domain = Get-ADDomain $fragments.Add("<H2>$($domain.dnsroot)</H2>") } #begin Process { 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 Write-Verbose "[$(Get-Date)] Creating report $ReportTitle version $reportversion saved to $path" $htmlParams.Body = $fragments | Out-String 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." } } #process End { Write-Verbose "[$(Get-Date)] Ending $($myinvocation.MyCommand)" } } #close function |