Every table cell fed from directory content (descriptions, sAMAccountName, OS strings, DNs, object classes) was interpolated into the Markdown report raw. python-markdown doesn't escape inline HTML by default, so a directory-controlled value like an OU description containing <script>... would render live once the report was converted to HTML with md_to_html.py -- and the underlying data is attacker-influenceable, not just operator-authored. Added md_escape() (Python) / ConvertTo-MdSafe (PowerShell), applied at every table-row interpolation in both scripts. Escapes &, <, > to HTML entities and | plus embedded newlines to keep the table structure intact. Raw JSON dumps are left untouched -- this only affects the Markdown/HTML presentation layer. Verified end-to-end with <script>, <img onerror=...>, embedded &, and embedded | payloads across every affected table in both scripts; confirmed no live tags reach the rendered HTML and no double-escaping occurs.
414 lines
18 KiB
PowerShell
414 lines
18 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Read-only Active Directory structure/health audit using the RSAT ActiveDirectory module.
|
|
|
|
.DESCRIPTION
|
|
Domain-joined-machine counterpart to ad_audit.py (the LDAP/ldap3 version). Produces the
|
|
same data points -- OUs, users, computers, groups, object type counts -- using
|
|
Get-AD* cmdlets instead of raw LDAP. Requires the ActiveDirectory PowerShell module
|
|
(RSAT) and only performs reads; no changes are made to the directory.
|
|
|
|
.PARAMETER Server
|
|
Domain controller to query. Defaults to the domain of the current user's logon.
|
|
|
|
.PARAMETER SearchBase
|
|
Distinguished name to scope the search to. Defaults to the domain root.
|
|
|
|
.PARAMETER StaleDays
|
|
Days of inactivity (LastLogonDate) before an enabled account is flagged stale. Default 90.
|
|
|
|
.PARAMETER Credential
|
|
Optional PSCredential to bind with. If omitted, uses the current logon session
|
|
(typical when run interactively on a domain-joined machine as a normal user).
|
|
|
|
.PARAMETER OutDir
|
|
Directory to write the Markdown report and raw JSON into. Default .\reports relative
|
|
to the current working directory.
|
|
|
|
.EXAMPLE
|
|
.\Invoke-ADAudit.ps1
|
|
|
|
.EXAMPLE
|
|
.\Invoke-ADAudit.ps1 -Server dc01.corp.example.com -SearchBase "OU=Corp,DC=corp,DC=example,DC=com" -StaleDays 120
|
|
|
|
.EXAMPLE
|
|
.\Invoke-ADAudit.ps1 -Credential (Get-Credential)
|
|
#>
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$Server,
|
|
[string]$SearchBase,
|
|
[int]$StaleDays = 90,
|
|
[System.Management.Automation.PSCredential]$Credential,
|
|
[string]$OutDir = ".\reports"
|
|
)
|
|
|
|
$ErrorActionPreference = "Stop"
|
|
|
|
if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) {
|
|
Write-Error "ActiveDirectory module not found. Install RSAT: Add-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0'"
|
|
exit 1
|
|
}
|
|
Import-Module ActiveDirectory -ErrorAction Stop
|
|
|
|
$adParams = @{}
|
|
if ($Server) { $adParams["Server"] = $Server }
|
|
if ($Credential) { $adParams["Credential"] = $Credential }
|
|
|
|
if (-not $SearchBase) {
|
|
$domain = Get-ADDomain @adParams
|
|
$SearchBase = $domain.DistinguishedName
|
|
}
|
|
|
|
Write-Host "Auditing base: $SearchBase"
|
|
if ($Server) { Write-Host "Domain controller: $Server" }
|
|
|
|
# --- UserAccountControl bit flags ---
|
|
$UAC_PASSWD_NOTREQD = 0x0020
|
|
$UAC_DONT_EXPIRE_PASSWD = 0x10000
|
|
$UAC_SMARTCARD_REQUIRED = 0x40000
|
|
$UAC_TRUSTED_FOR_DELEGATION = 0x80000
|
|
$UAC_DONT_REQ_PREAUTH = 0x400000
|
|
|
|
$now = Get-Date
|
|
$staleCutoff = $now.AddDays(-$StaleDays)
|
|
|
|
function ConvertTo-Rfc3339($DateTime) {
|
|
if (-not $DateTime) { return $null }
|
|
return $DateTime.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
|
|
}
|
|
|
|
# AD attributes (descriptions, sAMAccountName, OS strings, DNs, ...) are
|
|
# directory content, not report-generated text -- they can contain anything
|
|
# a writer or an attacker put there, including raw HTML. Markdown doesn't
|
|
# escape inline HTML by default, so an unescaped '<script>' in an OU
|
|
# description would render live when the report is viewed as HTML. This also
|
|
# neutralizes '|' and embedded newlines, which would otherwise corrupt the
|
|
# table row itself. Call on every AD-sourced value before it goes into a
|
|
# Markdown table cell.
|
|
function ConvertTo-MdSafe($Value) {
|
|
if ($null -eq $Value) { return "" }
|
|
$text = [string]$Value
|
|
$text = $text.Replace("&", "&").Replace("<", "<").Replace(">", ">")
|
|
$text = $text.Replace("|", "\|")
|
|
$text = ($text -replace "\s*[\r\n]+\s*", " ").Trim()
|
|
return $text
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Object type counts (whole subtree)
|
|
# ---------------------------------------------------------------------------
|
|
Write-Host " - object type counts..."
|
|
$allObjects = Get-ADObject -SearchBase $SearchBase -Filter * -Properties objectClass @adParams
|
|
$objectCounts = $allObjects | Group-Object -Property { $_.ObjectClass } | Sort-Object Count -Descending |
|
|
ForEach-Object { [PSCustomObject]@{ ObjectClass = $_.Name; Count = $_.Count } }
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OUs
|
|
# ---------------------------------------------------------------------------
|
|
Write-Host " - organizational units..."
|
|
$ous = Get-ADOrganizationalUnit -SearchBase $SearchBase -Filter * -Properties Description, whenCreated @adParams |
|
|
ForEach-Object {
|
|
[PSCustomObject]@{
|
|
DN = $_.DistinguishedName
|
|
Name = $_.Name
|
|
Description = $_.Description
|
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
|
Depth = ([regex]::Matches($_.DistinguishedName, "OU=")).Count
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Users
|
|
# ---------------------------------------------------------------------------
|
|
Write-Host " - users..."
|
|
$userProps = @(
|
|
"sAMAccountName", "userPrincipalName", "userAccountControl", "LastLogonDate",
|
|
"PasswordLastSet", "whenCreated", "adminCount", "MemberOf", "Enabled", "LockedOut"
|
|
)
|
|
$users = Get-ADUser -SearchBase $SearchBase -Filter * -Properties $userProps @adParams | ForEach-Object {
|
|
$uac = [int]$_.userAccountControl
|
|
$lastLogon = $_.LastLogonDate
|
|
$neverLoggedOn = -not $lastLogon
|
|
$stale = ($_.Enabled) -and $lastLogon -and ($lastLogon -lt $staleCutoff)
|
|
|
|
[PSCustomObject]@{
|
|
DN = $_.DistinguishedName
|
|
SamAccountName = $_.sAMAccountName
|
|
UPN = $_.userPrincipalName
|
|
Disabled = -not $_.Enabled
|
|
Locked = [bool]$_.LockedOut
|
|
PwdNeverExpires = [bool]($uac -band $UAC_DONT_EXPIRE_PASSWD)
|
|
PwdNotRequired = [bool]($uac -band $UAC_PASSWD_NOTREQD)
|
|
SmartcardRequired = [bool]($uac -band $UAC_SMARTCARD_REQUIRED)
|
|
TrustedForDelegation = [bool]($uac -band $UAC_TRUSTED_FOR_DELEGATION)
|
|
KerberosPreAuthDisabled = [bool]($uac -band $UAC_DONT_REQ_PREAUTH)
|
|
AdminCount = [bool]($_.adminCount -gt 0)
|
|
LastLogon = ConvertTo-Rfc3339 $lastLogon
|
|
NeverLoggedOn = $neverLoggedOn -and $_.Enabled
|
|
Stale = $stale
|
|
PasswordLastSet = ConvertTo-Rfc3339 $_.PasswordLastSet
|
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
|
GroupCount = (@($_.MemberOf)).Count
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Computers
|
|
# ---------------------------------------------------------------------------
|
|
Write-Host " - computers..."
|
|
$compProps = @("sAMAccountName", "userAccountControl", "LastLogonDate", "OperatingSystem",
|
|
"OperatingSystemVersion", "whenCreated", "Enabled")
|
|
$computers = Get-ADComputer -SearchBase $SearchBase -Filter * -Properties $compProps @adParams | ForEach-Object {
|
|
$lastLogon = $_.LastLogonDate
|
|
$stale = ($_.Enabled) -and $lastLogon -and ($lastLogon -lt $staleCutoff)
|
|
|
|
[PSCustomObject]@{
|
|
DN = $_.DistinguishedName
|
|
SamAccountName = $_.sAMAccountName
|
|
OS = $_.OperatingSystem
|
|
OSVersion = $_.OperatingSystemVersion
|
|
Disabled = -not $_.Enabled
|
|
LastLogon = ConvertTo-Rfc3339 $lastLogon
|
|
Stale = $stale
|
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Groups
|
|
# ---------------------------------------------------------------------------
|
|
Write-Host " - groups..."
|
|
$groups = Get-ADGroup -SearchBase $SearchBase -Filter * -Properties Description, whenCreated, Members, GroupCategory, GroupScope @adParams |
|
|
ForEach-Object {
|
|
$memberCount = (@($_.Members)).Count
|
|
[PSCustomObject]@{
|
|
DN = $_.DistinguishedName
|
|
SamAccountName = $_.SamAccountName
|
|
Type = $_.GroupCategory.ToString()
|
|
Scope = $_.GroupScope.ToString()
|
|
MemberCount = $memberCount
|
|
Empty = ($memberCount -eq 0)
|
|
Description = $_.Description
|
|
Created = ConvertTo-Rfc3339 $_.whenCreated
|
|
}
|
|
}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Write raw data
|
|
# ---------------------------------------------------------------------------
|
|
if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null }
|
|
$ts = Get-Date -Format "yyyyMMdd_HHmmss"
|
|
|
|
$raw = [PSCustomObject]@{
|
|
ObjectCounts = $objectCounts
|
|
OUs = $ous
|
|
Users = $users
|
|
Computers = $computers
|
|
Groups = $groups
|
|
}
|
|
$rawPath = Join-Path $OutDir "ad_audit_raw_$ts.json"
|
|
$raw | ConvertTo-Json -Depth 6 | Out-File -FilePath $rawPath -Encoding utf8
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Build Markdown report
|
|
# ---------------------------------------------------------------------------
|
|
Write-Host " - building report..."
|
|
|
|
# Aggregate stats up front so the executive summary and the detailed
|
|
# sections below both draw from the same computed values.
|
|
$maxDepth = ($ous | Measure-Object -Property Depth -Maximum).Maximum
|
|
|
|
$totalUsers = $users.Count
|
|
$disabled = ($users | Where-Object Disabled).Count
|
|
$enabled = $totalUsers - $disabled
|
|
$locked = ($users | Where-Object Locked).Count
|
|
$pwdNeverExpires = ($users | Where-Object PwdNeverExpires).Count
|
|
$pwdNotRequired = ($users | Where-Object PwdNotRequired).Count
|
|
$neverLoggedOn = ($users | Where-Object NeverLoggedOn).Count
|
|
$staleUsers = ($users | Where-Object Stale).Count
|
|
$adminCountFlagged = ($users | Where-Object AdminCount).Count
|
|
$trustedDeleg = ($users | Where-Object TrustedForDelegation).Count
|
|
$noPreauth = ($users | Where-Object KerberosPreAuthDisabled).Count
|
|
|
|
$totalComp = $computers.Count
|
|
$compDisabled = ($computers | Where-Object Disabled).Count
|
|
$compStale = ($computers | Where-Object Stale).Count
|
|
|
|
$totalGroups = $groups.Count
|
|
$securityGroups = ($groups | Where-Object { $_.Type -eq "Security" }).Count
|
|
$distributionGroups = $totalGroups - $securityGroups
|
|
$emptyGroups = ($groups | Where-Object Empty).Count
|
|
|
|
$sb = New-Object System.Text.StringBuilder
|
|
|
|
function Add-Line([string]$text = "") { [void]$sb.AppendLine($text) }
|
|
|
|
Add-Line "# Active Directory Audit Report"
|
|
Add-Line ""
|
|
Add-Line "- Search base: ``$SearchBase``"
|
|
if ($Server) { Add-Line "- Domain controller: ``$Server``" }
|
|
Add-Line "- Generated: $(ConvertTo-Rfc3339 $now)"
|
|
Add-Line "- Stale-account threshold: $StaleDays days of inactivity"
|
|
Add-Line ""
|
|
|
|
Add-Line "## Executive Summary"
|
|
Add-Line ""
|
|
Add-Line "- Users: $totalUsers total ($enabled enabled, $disabled disabled)"
|
|
Add-Line "- Computers: $totalComp total ($compDisabled disabled)"
|
|
Add-Line "- Groups: $totalGroups total ($securityGroups security, $distributionGroups distribution)"
|
|
Add-Line "- Max OU nesting depth: $maxDepth"
|
|
Add-Line ""
|
|
|
|
# Each finding: Severity, Title, Notes, column labels for the detail table, matching rows.
|
|
$USER_COLS = @("Account", "Last Logon", "DN")
|
|
function Format-Finding($Severity, $Title, $Notes, $Cols, $Rows) {
|
|
[PSCustomObject]@{ Severity = $Severity; Title = $Title; Notes = $Notes; Cols = $Cols; Rows = @($Rows) }
|
|
}
|
|
function UserRows($Predicate) {
|
|
$users | Where-Object $Predicate | Sort-Object SamAccountName | ForEach-Object {
|
|
[PSCustomObject]@{ Col0 = $_.SamAccountName; Col1 = $_.LastLogon; DN = $_.DN }
|
|
}
|
|
}
|
|
|
|
$findings = New-Object System.Collections.Generic.List[Object]
|
|
if ($pwdNotRequired -gt 0) {
|
|
$findings.Add((Format-Finding "Critical" "User accounts allowing blank passwords" "PASSWD_NOTREQD flag set; remove unless there is a specific reason" $USER_COLS (UserRows { $_.PwdNotRequired })))
|
|
}
|
|
if ($noPreauth -gt 0) {
|
|
$findings.Add((Format-Finding "Critical" "AS-REP roastable accounts (Kerberos pre-auth disabled)" "Offline password cracking risk; re-enable pre-auth unless required" $USER_COLS (UserRows { $_.KerberosPreAuthDisabled })))
|
|
}
|
|
if ($trustedDeleg -gt 0) {
|
|
$findings.Add((Format-Finding "Critical" "Accounts trusted for unconstrained delegation" "High-value targets for credential theft; move to constrained/no delegation" $USER_COLS (UserRows { $_.TrustedForDelegation })))
|
|
}
|
|
if ($locked -gt 0) {
|
|
$findings.Add((Format-Finding "High" "Currently locked-out user accounts" "May indicate attack activity or stale service credentials" $USER_COLS (UserRows { $_.Locked })))
|
|
}
|
|
if ($staleUsers -gt 0) {
|
|
$findings.Add((Format-Finding "Medium" "Stale enabled user accounts (>$StaleDays days inactive)" "Candidates for disable/offboarding review" $USER_COLS (UserRows { $_.Stale })))
|
|
}
|
|
if ($compStale -gt 0) {
|
|
$compRows = $computers | Where-Object Stale | Sort-Object SamAccountName | ForEach-Object {
|
|
[PSCustomObject]@{ Col0 = $_.SamAccountName; Col1 = $_.LastLogon; DN = $_.DN }
|
|
}
|
|
$findings.Add((Format-Finding "Medium" "Stale enabled computer accounts (>$StaleDays days inactive)" "Likely decommissioned hardware still trusted in the domain" @("Computer", "Last Logon", "DN") $compRows))
|
|
}
|
|
if ($emptyGroups -gt 0) {
|
|
$emptyRows = $groups | Where-Object Empty | Sort-Object SamAccountName | ForEach-Object {
|
|
[PSCustomObject]@{ Col0 = $_.SamAccountName; Col1 = $_.Type; DN = $_.DN }
|
|
}
|
|
$findings.Add((Format-Finding "Medium" "Empty security/distribution groups" "Cleanup candidates ahead of OU/group reorg" @("Group", "Type", "DN") $emptyRows))
|
|
}
|
|
if ($pwdNeverExpires -gt 0) {
|
|
$findings.Add((Format-Finding "Medium" "Accounts with password-never-expires set" "Review against password policy; exempt only where justified" $USER_COLS (UserRows { $_.PwdNeverExpires })))
|
|
}
|
|
if ($neverLoggedOn -gt 0) {
|
|
$findings.Add((Format-Finding "Low" "Enabled accounts that have never logged on" "Possibly unused/orphaned provisioning; verify before disabling" $USER_COLS (UserRows { $_.NeverLoggedOn })))
|
|
}
|
|
if ($adminCountFlagged -gt 0) {
|
|
$findings.Add((Format-Finding "Info" "Accounts with adminCount=1 (current or former privileged)" "SDProp-protected ACLs persist even after privilege is removed; review membership" $USER_COLS (UserRows { $_.AdminCount })))
|
|
}
|
|
|
|
$severityOrder = @{ "Critical" = 0; "High" = 1; "Medium" = 2; "Low" = 3; "Info" = 4 }
|
|
$findings = $findings | Sort-Object { $severityOrder[$_.Severity] }
|
|
|
|
if ($findings.Count -gt 0) {
|
|
Add-Line "### Risk & Cleanup Findings"
|
|
Add-Line ""
|
|
Add-Line "| Severity | Finding | Count | Notes |"
|
|
Add-Line "|---|---|---|---|"
|
|
foreach ($f in $findings) { Add-Line "| $($f.Severity) | $(ConvertTo-MdSafe $f.Title) | $($f.Rows.Count) | $(ConvertTo-MdSafe $f.Notes) |" }
|
|
Add-Line ""
|
|
|
|
Add-Line "### Finding Detail Lists"
|
|
Add-Line ""
|
|
foreach ($f in $findings) {
|
|
Add-Line "#### [$($f.Severity)] $($f.Title)"
|
|
Add-Line ""
|
|
Add-Line "| $(ConvertTo-MdSafe $f.Cols[0]) | $(ConvertTo-MdSafe $f.Cols[1]) | $(ConvertTo-MdSafe $f.Cols[2]) |"
|
|
Add-Line "|---|---|---|"
|
|
foreach ($row in $f.Rows) { Add-Line "| $(ConvertTo-MdSafe $row.Col0) | $(ConvertTo-MdSafe $row.Col1) | $(ConvertTo-MdSafe $row.DN) |" }
|
|
Add-Line ""
|
|
}
|
|
} else {
|
|
Add-Line "No notable risk or cleanup findings surfaced by this audit's checks."
|
|
Add-Line ""
|
|
}
|
|
|
|
Add-Line "## Object Type Counts"
|
|
Add-Line ""
|
|
Add-Line "| Object Class | Count |"
|
|
Add-Line "|---|---|"
|
|
foreach ($row in $objectCounts) { Add-Line "| $(ConvertTo-MdSafe $row.ObjectClass) | $($row.Count) |" }
|
|
Add-Line ""
|
|
|
|
Add-Line "## Organizational Units"
|
|
Add-Line ""
|
|
Add-Line "- Total OUs: $($ous.Count)"
|
|
Add-Line "- Maximum nesting depth: $maxDepth"
|
|
Add-Line ""
|
|
Add-Line "| OU DN | Depth | Description |"
|
|
Add-Line "|---|---|---|"
|
|
foreach ($o in ($ous | Sort-Object DN)) { Add-Line "| $(ConvertTo-MdSafe $o.DN) | $($o.Depth) | $(ConvertTo-MdSafe $o.Description) |" }
|
|
Add-Line ""
|
|
|
|
Add-Line "## Users"
|
|
Add-Line ""
|
|
Add-Line "- Total user objects: $totalUsers"
|
|
Add-Line "- Enabled: $enabled"
|
|
Add-Line "- Disabled: $disabled"
|
|
Add-Line "- Currently locked out: $locked"
|
|
Add-Line "- Password never expires: $pwdNeverExpires"
|
|
Add-Line "- Password not required (blank password allowed): **$pwdNotRequired**"
|
|
Add-Line "- Enabled but never logged on: $neverLoggedOn"
|
|
Add-Line "- Stale (enabled, inactive > $StaleDays days): $staleUsers"
|
|
Add-Line "- adminCount=1 (protected/privileged, incl. historical): $adminCountFlagged"
|
|
Add-Line "- Trusted for unconstrained delegation: **$trustedDeleg**"
|
|
Add-Line "- Kerberos pre-auth disabled (AS-REP roastable): **$noPreauth**"
|
|
Add-Line ""
|
|
|
|
Add-Line "## Computers"
|
|
Add-Line ""
|
|
Add-Line "- Total computer objects: $totalComp"
|
|
Add-Line "- Disabled: $compDisabled"
|
|
Add-Line "- Stale (enabled, inactive > $StaleDays days): $compStale"
|
|
Add-Line ""
|
|
Add-Line "### OS breakdown"
|
|
Add-Line ""
|
|
Add-Line "| Operating System | Count |"
|
|
Add-Line "|---|---|"
|
|
$osGroups = $computers | Group-Object -Property { if ($_.OS) { $_.OS } else { "Unknown" } } | Sort-Object Count -Descending
|
|
foreach ($g in $osGroups) { Add-Line "| $(ConvertTo-MdSafe $g.Name) | $($g.Count) |" }
|
|
Add-Line ""
|
|
|
|
Add-Line "## Groups"
|
|
Add-Line ""
|
|
Add-Line "- Total groups: $totalGroups"
|
|
Add-Line "- Security groups: $securityGroups"
|
|
Add-Line "- Distribution groups: $distributionGroups"
|
|
Add-Line "- Empty groups (0 members): $emptyGroups"
|
|
Add-Line ""
|
|
Add-Line "| Scope | Count |"
|
|
Add-Line "|---|---|"
|
|
$scopeGroups = $groups | Group-Object -Property Scope | Sort-Object Count -Descending
|
|
foreach ($g in $scopeGroups) { Add-Line "| $(ConvertTo-MdSafe $g.Name) | $($g.Count) |" }
|
|
Add-Line ""
|
|
|
|
Add-Line "### Largest groups (top 15 by member count)"
|
|
Add-Line ""
|
|
Add-Line "| Group | Type | Scope | Members |"
|
|
Add-Line "|---|---|---|---|"
|
|
foreach ($g in ($groups | Sort-Object -Property MemberCount -Descending | Select-Object -First 15)) {
|
|
Add-Line "| $(ConvertTo-MdSafe $g.SamAccountName) | $(ConvertTo-MdSafe $g.Type) | $(ConvertTo-MdSafe $g.Scope) | $($g.MemberCount) |"
|
|
}
|
|
Add-Line ""
|
|
|
|
$reportPath = Join-Path $OutDir "ad_audit_report_$ts.md"
|
|
$sb.ToString() | Out-File -FilePath $reportPath -Encoding utf8
|
|
|
|
Write-Host ""
|
|
Write-Host "Done."
|
|
Write-Host " Raw data: $rawPath"
|
|
Write-Host " Report: $reportPath"
|