Q: I just had an Active Directory (AD) or Active Directory Security health assessment from Microsoft, and they found some stuff I didn’t know about, or they found other items I knew to check for but forgot about. While I want to get another assessment done in another year or two, I want to stay on top of some of these items in the meantime. Do you have an easy way to monitor known problems?
A: Yes! It is easy to forget about some one-off configurations that were only done temporarily, or someone can make a mistake and forget to catch it. While the Microsoft assessments catch a ton of items that are much more complex, we can make a simple script check common problems regularly.
There are a lot of configuration options in Active Directory Domain Services (ADDS) and Microsoft assessments are the best way to find problems with configurations, but some items are customized for each customer. The other day a customer mentioned that they wanted to scan for accounts that had not logged in recently and disable the unused accounts, so they could investigate if they are needed anymore and decommission them. They wanted to track a few other items though, so I wrote the following script. This way items that are easy to fix, but hard to remember, are automatically checked for them.
Disclaimer: The sample scripts are not supported under any Microsoft standard support program or service. The sample scripts are provided AS IS without warranty of any kind. Microsoft further disclaims all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a particular purpose. The entire risk arising out of the use or performance of the sample scripts and documentation remains with you. In no event shall Microsoft, its authors, or anyone else involved in the creation, production, or delivery of the scripts be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary loss) arising out of the use of or inability to use the sample scripts or documentation, even if Microsoft has been advised of the possibility of such damages.
I will walk through queries that can be used to measure several different items, then how to make them a little fancier, then show the whole script that I put together the other day. It is easy to modify if you want slightly different information. If you just want a copy of the code, you can get a copy here.
Occasionally administrators join a computer to the domain and forget to move the computer object to the proper Organizational Unit (OU). If computers are not in the proper OU, then they will not get the proper Group Policy Objects (GPOs) applied, which can cause unexpected behavior.
The following query checks for any forgotten computers in the Computers container:
Get-ADObject -SearchBase "CN=Computers,DC=Contoso,DC=com" -Filter *
The Users container should not have the accounts for admins or users in it. To get all the objects in the Users container, we use a very similar query:
Get-ADObject -SearchBase "CN=Users,DC=Contoso,DC=com" -Filter *
Unfortunately, this gives a bunch of items that we don’t want to report on, since we don’t mind some default objects from when the domain was created being in our Users container. To mitigate this, I remove critical system objects which include items like krbtgt, the RID-500 account, and groups like Domain Admins, and Domain Users. I also ignore accounts used by AAD (Azure Active Directory) Connect. If you have Exchange installed in your environment and have SystemMailbox objects in Users then you could exclude those too.
$NormalObjectsInUsers = @('DnsAdmins', 'DnsUpdateProxy') #This can be populated with additional exceptions you want to allow
$ExtraObjectsInUsersContainer = Get-ADObject -SearchBase $('CN=Users,' + $DomainDN) -Filter { ObjectClass -ne 'container' } -Properties isCriticalSystemObject, samaccountname -Server $domain |
Where-Object { $_.SamAccountName -notin $NormalObjectsInUsers -and $_.isCriticalSystemObject -ne $true -and $_.SamAccountName -ne $Null -and -not($_.samaccountname.startswith('AAD_')) -and -not($_.samaccountname.startswith('MOL_')) -and ($_.samaccountname -ne 'SUPPORT_388945a0') -and -not($_.samaccountname -like 'CAS_*}*') }
Test GPOs can be created and forgotten, so I like a report of all my unlinked GPOs so that I can clean them up. This checks for all unlinked GPOs. Even in small environments, this takes several seconds to run.
Get-GPO -All | Where-Object {$_ | Get-GPOReport -ReportType XML | Select-String -NotMatch "<LinksTo>"}
Most organizations require users to change their password periodically. Admins sometimes fall behind on changing passwords for service accounts and there are accounts that get forgotten about and are not used, so this checks for all enabled accounts that have not had the password changed within the last year.
$old = (Get-Date).AddDays(-365)
Get-ADUser -Filter {passwordLastSet -lt $old -and Enabled -eq $true} -Properties PasswordLastSet
I like to check the operating system of the computers in AD to see if there are any old computers running somewhere long forgotten that need to be decommissioned or upgraded:
get-adcomputer -filter * -properties operatingsystem | Group-Object operatingsystem | Sort-Object count -descending | Select-Object count, name
I wanted a few more items checked, and I wanted it to check other domains that trust the one I’m running it in. I also wanted the results in a pretty format for admins to read, so I came up with the following:
#Monthly AD Health Checks
#Requires -Modules grouppolicy
#Requires -Modules dhcpserver
#Requires -Modules activedirectory
<#
.SYNOPSIS
Runs some simple checks against AD and generates a report that should be reviewed on a regular basis
.DESCRIPTION
This script is designed to be used as a framework for others to modify as they see fit for their environments, not as a one-size-fits-all solution.
If you would like a more in-depth and sophisticated scan pleast contact Microsoft for an AD assessment.
This is intended to run as a scheduled task or as part of a SCORCH runbook and has a hardcoded value.
.NOTES
Author: Paul Harrison
#>
$LogFile = '\\servername\share\path\ADReport.html'
#Values to define if you want it to email automatically - also uncomment the last line of this file
<#
$Body = "Please review the attached file for AD recommendations. The attached report has shortcuts to each section that are not in the body of the email.`n$HTMLReport"
$SubjectLine = "Recurring AD Report"
$ToAddress = @("Alice@contoso.com","Bob@contoso.com","Carmet@contoso.com","David@contoso.com")
$FromAddress = "DoNotReply@contoso.com"
$SMTPServer = 'mail.contoso.com'
#>
Function New-HTMLReport {
[CmdletBinding()]
param (
[parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]
$Title
)
"<H1 id=$('"'+$($Title.Replace(' ',''))+'"')>$($title)</H1><br>`n"
}
Function New-HTMLReportSection {
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateNotNullOrEmpty()]
[string]
$SectionTitle,
[Parameter()]
[array]
$SectionContents = $Null
)
$emptyNote = [PSCustomObject]@{message = '[empty]' }
$MyOut = @()
$MyOut += "<br><H2 id=$('"'+$($SectionTitle.Replace(' ',''))+'"')>$SectionTitle</H2>`n"
If ($SectionContents -eq '' -or $SectionContents -eq $Null) {
$MyOut += "<br>$($emptyNote | Select-Object message | ConvertTo-HTML -Fragment)`n"
}
Else {
$MyOut += "<br>$($SectionContents | ConvertTo-Html -Fragment)`n"
}
$MyOut
}
#Find domains to run this report against
$Trusts = Get-ADTrust -Filter * | Where-Object { $_.Direction -in @([Microsoft.ActiveDirectory.Management.ADTrustDirection]::BiDirectional, [Microsoft.ActiveDirectory.Management.ADTrustDirection]::Inbound) }
$FoundForestToRunAgainst = $Trusts | ForEach-Object { try { Get-ADForest $_.name; if ($?) { $_.Name } }catch {} }
$AdditionalDomainsToRunAgainst = ForEach ($Forest in $FoundForestToRunAgainst) {
ForEach ($domain in $Forest.Domains) {
try { $Null = Get-ADDomain $domain; if ($?) { domain } }catch {}
}
}
$TargetDomains = [array]$AdditionalDomainsToRunAgainst + (Get-ADDomain).DNSRoot | Select-Object -Unique
[string]$HTMLReport = ''
ForEach ($domain in $TargetDomains) {
$HTMLReport += New-HTMLReport -Title "AD Report for $domain on $((Get-Date).ToShortDateString()) at $((Get-Date).ToLongTimeString())"
$DomainDN = (Get-ADDomain -Server $domain).distinguishedName
#get objects in the computers container
$ObjectInComputerContainer = Get-ADObject -SearchBase $('CN=Computers,' + $DomainDN) -Filter { ObjectClass -ne 'container' } -Server $domain
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Objects in the Computers container" -SectionContents $($ObjectInComputerContainer | Select-Object name, objectclass, objectguid)
#Get objects in the users container
$NormalObjectsInUsers = @('DnsAdmins', 'DnsUpdateProxy') #This can be populated with exceptions like built in objects
$ExtraObjectsInUsersContainer = Get-ADObject -SearchBase $('CN=Users,' + $DomainDN) -Filter { ObjectClass -ne 'container' } -Properties isCriticalSystemObject, samaccountname -Server $domain |
Where-Object { $_.SamAccountName -notin $NormalObjectsInUsers -and $_.isCriticalSystemObject -ne $true -and $_.SamAccountName -ne $Null -and -not($_.samaccountname.startswith('AAD_')) -and -not($_.samaccountname.startswith('MOL_')) -and ($_.samaccountname -ne 'SUPPORT_388945a0') -and -not($_.samaccountname -like 'CAS_*}*') }
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Extra objects in the Users container" -SectionContents $($ExtraObjectsInUsersContainer | Select-Object name, objectclass)
#Find all empty groups
$emptyGroups = Get-ADGroup -Filter {isCriticalSystemObject -ne $true} -Properties members,isCriticalSystemObject -Server $domain | Where-Object { $($_.members.count) -eq 0 }
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Empty groups" -SectionContents $($emptyGroups | Select-Object samaccountname, distinguishedName)
#Find DCs not protected from accidental deletion
$DCsUnprotectedFromAccidentalDeletion = ((get-addomaincontroller -filter * -Server $domain).computerObjectDN | Get-ADObject -Server $domain -properties ProtectedFromAccidentalDeletion | Where-Object { -not $_.ProtectedFromAccidentalDeletion })
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - DCs not protected from accidental deletion" -SectionContents $($DCsUnprotectedFromAccidentalDeletion | Select-Object name)
#OUs not protected from accidental deletion
$OUsUnprotectedFromAccidentalDeletion = ((Get-ADOrganizationalUnit -Filter * -Server $domain).DistinguishedName | Get-ADObject -properties ProtectedFromAccidentalDeletion -Server $domain | Where-Object { -not $_.ProtectedFromAccidentalDeletion })
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - OUs not protected from accidental deletion" -SectionContents $($OUsUnprotectedFromAccidentalDeletion | Select-Object distinguishedName)
#Find computers with the DHCP server naming convention that are not authorized - comment out this section if you don't want to run against DHCP servers - don't forget to remove the #Requires for DHCPserrver at the top too
If ($domain -eq (Get-ADDomain).dnsroot) {
#only works against the domain this machine is on
$AuthorizedDHCPServers = get-dhcpServerInDC
$UnauthorizedDHCPServers = (get-adcomputer -filter { samaccountname -like '*pattern*' }).HostName | Where-Object { $_.dnsHostName -notin $AuthorizedDHCPServers.dnsName }
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Servers with the name of a DHCP server that are not authorized DHCP servers" -SectionContents $($UnauthorizedDHCPServers | Select-Object name)
}
#Disabled computer objects
$DisabledComputerObjects = Get-ADComputer -filter * -Properties whencreated, LastLogonDate -Server $domain | Where-Object { -not $_.Enabled }
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Disabled computer objects" -SectionContents $($DisabledComputerObjects | Select-Object name, distinguishedname, whencreated, lastlogondate)
#disabled users
$DisabledUserObjects = Get-ADUser -filter { Name -notlike 'SystemMailbox*' } -properties whencreated, lastlogondate, memberof -Server $domain | Where-Object { -not $_.enabled }
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Disabled user objects" -SectionContents $($DisabledUserObjects | Select-Object samaccountname, enabled, lastlogondate)
#Tombstone info
$TombstoneLifetime = (Get-ADObject -Identity "CN=Directory Service,CN=Windows NT,CN=services,$((Get-ADRootDSE -Server $domain).configurationNamingContext)" -properties tombstoneLifetime -Server $domain).tombstoneLifetime
$TombstoneDate = (get-Date).AddDays(-1 * $TombstoneLifetime)
#Computer objects with lastlogondate older than tombstone
$oldComputers = Get-ADComputer -filter { LastLogonDate -lt $TombstoneDate } -properties LastLogonDate -Server $domain
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Computers with lastLogonDate older than the tombstone - $TombstoneLifetime days ago - $($TombstoneDate.ToShortDateString())" -SectionContents $($oldComputers | Select-Object name, lastlogondate, distinguishedname)
#Disabled user objects with group membership
$DisabledUsersWithGroupMembership = $DisabledUserObjects | Where-Object { $_.memberof.count -eq 0 }
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Disabled users with group membership" -SectionContents $($DisabledUsersWithGroupMembership | Select-Object name, givenname, surname, enabled, whencreated, lastlogondate, distinguishedname)
#summary of computers by OS
$ComputersByOS = get-adcomputer -filter * -properties operatingsystem -Server $domain | Group-Object operatingsystem | Sort-Object count -descending | Select-Object count, name
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Summary of computers by OS" -SectionContents $ComputersByOS
#users with admincount = 1
$UsersWithAdminCount1 = Get-ADUser -filter { admincount -eq 1 } -Server $domain
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Users with admincount = 1" -SectionContents $($UsersWithAdminCount1 | Select-Object name, givenname, surname)
#Find unlinked GPOs
$UnlinkedGPOs = Get-GPO -All -Domain $domain | Where-Object { $_ | Get-GPOReport -ReportType XML -Domain $domain | Select-String -NotMatch '<LinksTo>' }
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Unlinked GPOs" -SectionContents $($UnlinkedGPOs | Select-Object displayname, creationtime, modificationtime, @{N='WmiFilter';E={$_.wmifilter.Name}})
#Users without a password required
$UsersWithoutAPasswordRequired = Get-ADUser -Filter { PasswordNotRequired -eq $true } -Properties passwordNotRequired -Server $domain
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Users without a password required (passwordNotRequired = true)" -SectionContents $($UsersWithoutAPasswordRequired | Select-Object samaccountname)
#User objects with PasswordNeverExpires = true that are not in a service accounts OU
$PwdNeverExpires = Get-ADUser -Filter { PasswordNeverExpires -eq $true -and samaccountname -notlike 'HealthMailbox*' } -Properties PasswordNeverExpires -Server $domain | Where-Object { $_.distinguishedName -notlike "*OU=Service Accounts*,$DomainDN" }
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Users with PasswordNeverExpires = true that are not in a service accounts OU" -SectionContents $($PwdNeverExpires | Select-Object samaccountname, enabled, name, passwordneverexpires, distinguishedname)
#Users with a passwordLastSet over 1 year old
$old = (Get-Date).AddDays(-365)
$OldUserObjects = Get-ADUser -Filter { passwordLastSet -lt $old -and samaccountname -notlike 'HealthMailbox*' -and Enabled -eq $true } -Properties PasswordLastSet -Server $domain
$HTMLReport += New-HTMLReportSection -SectionTitle "$domain - Enabled User Objects with a password over 1 year old or never set" -SectionContents $($OldUserObjects | Select-Object samaccountname, enabled, name, passwordneverexpires, distinguishedname)
} #finished collecting data
#generate a table of contents with links to each item
$HTMLObject = New-Object -ComObject 'HTMLFile'
$HTMLObject.write([System.Text.Encoding]::Unicode.GetBytes($HTMLReport))
$AllIDLines = $HTMLObject.all.tags('H2')
$AllIDs = forEach ($line in $AllIDLines) {
$start = $line.outerHTML.IndexOf('id=') + 3
$end = $line.outerHTML.IndexOf('>', $start) - 1
$end2 = $line.outerHTML.IndexOf('<', $($end + 2))
[pscustomobject]@{
ID = $line.outerHTML[$start..$end] -join ('')
Title = $line.outerHTML[$($end + 2)..$($end2 - 1)] -join ('')
}
}
$TableOfContents = forEach ($ID in $AllIDs) {
'<a href=#' + $($ID.id) + '>' + $($ID.Title) + '</a><br>'
}
$HTMLReportWithTOC = $TableOfContents + $HTMLReport
#output
If (Test-Path $LogFile) {
Remove-Item $LogFile -Force
}
$HTMLReportWithTOC | Out-File $LogFile -Force
#Send-MailMessage -Attachments $LogFile -BodyAsHtml $Body -Subject $SubjectLine -To $ToAddress -From $FromAddress -SmtpServer $SMTPServer
Some sample output:
More elegant formatting could have been done with other PowerShell modules or more time, but my customer wanted to review any PowerShell modules before loading them into the environment, so this quick and simple way worked for my purposes. I wanted the code to be easy to modify, so now if the customer wants to add one more item to monitor regularly, they simply add 2-3 lines of code and they get an automatic report. I’ve deployed this as a System Center Orchestrator runbook if it is available or as a scheduled task in other networks.
Regular maintenance and monitoring of Active Directory are important to maintain security and proper configuration. This script can help even in environments without fancy monitoring tools like SCOM.
Have fun scripting!
Additional reading:
- A copy of the code: https://github.com/PaulHCode/RecurringADChecks/blob/main/RecurringADChecks.ps1
- Active Directory module for PowerShell: https://docs.microsoft.com/en-us/powershell/module/activedirectory/
- On Demand Assessments: https://serviceshub.microsoft.com/assessments/
- Setting up On Demand Active Directory Assessments: https://www.microsoft.com/en-us/videoplayer/embed/RWwTzK
- Setting up On Demand Active Directory Security Assessments: https://www.microsoft.com/en-us/videoplayer/embed/RWwWaK
- Offline Assessment for Active Directory: OfflinePrereq (windows.net)
- Offline Assessment for Active Directory Security: Pre-Reqs for Active Directory Security (windows.net)
- Offline Assessments: Purchased Offline assessments are now available on the Services Hub | Microsoft Docs
Posted at https://sl.advdat.com/3FCLR4z