Add executive summary with prioritized risk findings to both reports

Surfaces blank-password, AS-REP roastable, unconstrained delegation,
lockout, stale-account, empty-group, and password-never-expires
counts as a severity-ranked findings table at the top of the report,
ahead of the full detail tables -- useful as a reorg-planning summary.
This commit is contained in:
2026-08-21 16:17:44 -04:00
parent 60cbfadbae
commit 903f64a40b
2 changed files with 214 additions and 59 deletions
+77 -37
View File
@@ -193,38 +193,11 @@ $raw | ConvertTo-Json -Depth 6 | Out-File -FilePath $rawPath -Encoding utf8
# Build Markdown report
# ---------------------------------------------------------------------------
Write-Host " - building report..."
$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: $($now.ToUniversalTime().ToString("o"))"
Add-Line "- Stale-account threshold: $StaleDays days of inactivity"
Add-Line ""
Add-Line "## Object Type Counts"
Add-Line ""
Add-Line "| Object Class | Count |"
Add-Line "|---|---|"
foreach ($row in $objectCounts) { Add-Line "| $($row.ObjectClass) | $($row.Count) |" }
Add-Line ""
Add-Line "## Organizational Units"
Add-Line ""
Add-Line "- Total OUs: $($ous.Count)"
# 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
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 "| $($o.DN) | $($o.Depth) | $($o.Description) |" }
Add-Line ""
Add-Line "## Users"
Add-Line ""
$totalUsers = $users.Count
$disabled = ($users | Where-Object Disabled).Count
$enabled = $totalUsers - $disabled
@@ -237,6 +210,81 @@ $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: $($now.ToUniversalTime().ToString("o"))"
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 ""
$findings = New-Object System.Collections.Generic.List[Object]
if ($pwdNotRequired -gt 0) { $findings.Add(@("Critical", "User accounts allowing blank passwords", $pwdNotRequired, "PASSWD_NOTREQD flag set; remove unless there is a specific reason")) }
if ($noPreauth -gt 0) { $findings.Add(@("Critical", "AS-REP roastable accounts (Kerberos pre-auth disabled)", $noPreauth, "Offline password cracking risk; re-enable pre-auth unless required")) }
if ($trustedDeleg -gt 0) { $findings.Add(@("Critical", "Accounts trusted for unconstrained delegation", $trustedDeleg, "High-value targets for credential theft; move to constrained/no delegation")) }
if ($locked -gt 0) { $findings.Add(@("High", "Currently locked-out user accounts", $locked, "May indicate attack activity or stale service credentials")) }
if ($staleUsers -gt 0) { $findings.Add(@("Medium", "Stale enabled user accounts (>$StaleDays days inactive)", $staleUsers, "Candidates for disable/offboarding review")) }
if ($compStale -gt 0) { $findings.Add(@("Medium", "Stale enabled computer accounts (>$StaleDays days inactive)", $compStale, "Likely decommissioned hardware still trusted in the domain")) }
if ($emptyGroups -gt 0) { $findings.Add(@("Medium", "Empty security/distribution groups", $emptyGroups, "Cleanup candidates ahead of OU/group reorg")) }
if ($pwdNeverExpires -gt 0) { $findings.Add(@("Medium", "Accounts with password-never-expires set", $pwdNeverExpires, "Review against password policy; exempt only where justified")) }
if ($neverLoggedOn -gt 0) { $findings.Add(@("Low", "Enabled accounts that have never logged on", $neverLoggedOn, "Possibly unused/orphaned provisioning; verify before disabling")) }
if ($adminCountFlagged -gt 0) { $findings.Add(@("Info", "Accounts with adminCount=1 (current or former privileged)", $adminCountFlagged, "SDProp-protected ACLs persist even after privilege is removed; review membership")) }
$severityOrder = @{ "Critical" = 0; "High" = 1; "Medium" = 2; "Low" = 3; "Info" = 4 }
$findings = $findings | Sort-Object { $severityOrder[$_[0]] }
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[0]) | $($f[1]) | $($f[2]) | $($f[3]) |" }
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 "| $($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 "| $($o.DN) | $($o.Depth) | $($o.Description) |" }
Add-Line ""
Add-Line "## Users"
Add-Line ""
Add-Line "- Total user objects: $totalUsers"
Add-Line "- Enabled: $enabled"
Add-Line "- Disabled: $disabled"
@@ -263,9 +311,6 @@ if ($staleUsers -gt 0) {
Add-Line "## Computers"
Add-Line ""
$totalComp = $computers.Count
$compDisabled = ($computers | Where-Object Disabled).Count
$compStale = ($computers | Where-Object Stale).Count
Add-Line "- Total computer objects: $totalComp"
Add-Line "- Disabled: $compDisabled"
Add-Line "- Stale (enabled, inactive > $StaleDays days): $compStale"
@@ -280,11 +325,6 @@ Add-Line ""
Add-Line "## Groups"
Add-Line ""
$totalGroups = $groups.Count
$securityGroups = ($groups | Where-Object { $_.Type -eq "Security" }).Count
$distributionGroups = $totalGroups - $securityGroups
$emptyGroups = ($groups | Where-Object Empty).Count
Add-Line "- Total groups: $totalGroups"
Add-Line "- Security groups: $securityGroups"
Add-Line "- Distribution groups: $distributionGroups"
+137 -22
View File
@@ -257,6 +257,121 @@ def audit_object_counts(conn, base_dn):
return counter
def compute_stats(data, args):
ous = data["ous"]
users = data["users"]
computers = data["computers"]
groups = data["groups"]
max_depth = max((o["depth"] for o in ous), default=0)
total_users = len(users)
disabled = sum(1 for u in users if u["disabled"])
stats = {
"total_users": total_users,
"enabled": total_users - disabled,
"disabled": disabled,
"locked": sum(1 for u in users if u["locked"]),
"pwd_never_expires": sum(1 for u in users if u["pwd_never_expires"]),
"pwd_not_required": sum(1 for u in users if u["pwd_not_required"]),
"never_logged_on": sum(1 for u in users if u["never_logged_on"] and not u["disabled"]),
"stale": sum(1 for u in users if u["stale"]),
"admin_count_flagged": sum(1 for u in users if u["admin_count"]),
"trusted_deleg": sum(1 for u in users if u["trusted_for_delegation"]),
"no_preauth": sum(1 for u in users if u["kerberos_preauth_disabled"]),
}
total_comp = len(computers)
comp_disabled = sum(1 for c in computers if c["disabled"])
comp_stats = {
"total": total_comp,
"disabled": comp_disabled,
"stale": sum(1 for c in computers if c["stale"]),
"os_counter": Counter(c["os"] or "Unknown" for c in computers),
}
total_groups = len(groups)
security_groups = sum(1 for g in groups if g["type"] == "Security")
group_stats = {
"total": total_groups,
"security": security_groups,
"distribution": total_groups - security_groups,
"empty": sum(1 for g in groups if g["empty"]),
"scope_counter": Counter(g["scope"] for g in groups),
}
return {
"max_ou_depth": max_depth,
"users": stats,
"computers": comp_stats,
"groups": group_stats,
}
def build_executive_summary(stats, args):
u = stats["users"]
c = stats["computers"]
g = stats["groups"]
findings = []
if u["pwd_not_required"]:
findings.append(("Critical", "User accounts allowing blank passwords", u["pwd_not_required"],
"PASSWD_NOTREQD flag set; remove unless there is a specific reason"))
if u["no_preauth"]:
findings.append(("Critical", "AS-REP roastable accounts (Kerberos pre-auth disabled)", u["no_preauth"],
"Offline password cracking risk; re-enable pre-auth unless required"))
if u["trusted_deleg"]:
findings.append(("Critical", "Accounts trusted for unconstrained delegation", u["trusted_deleg"],
"High-value targets for credential theft; move to constrained/no delegation"))
if u["locked"]:
findings.append(("High", "Currently locked-out user accounts", u["locked"],
"May indicate attack activity or stale service credentials"))
if u["stale"]:
findings.append(("Medium", f"Stale enabled user accounts (>{args.stale_days}d inactive)", u["stale"],
"Candidates for disable/offboarding review"))
if c["stale"]:
findings.append(("Medium", f"Stale enabled computer accounts (>{args.stale_days}d inactive)", c["stale"],
"Likely decommissioned hardware still trusted in the domain"))
if g["empty"]:
findings.append(("Medium", "Empty security/distribution groups", g["empty"],
"Cleanup candidates ahead of OU/group reorg"))
if u["pwd_never_expires"]:
findings.append(("Medium", "Accounts with password-never-expires set", u["pwd_never_expires"],
"Review against password policy; exempt only where justified"))
if u["never_logged_on"]:
findings.append(("Low", "Enabled accounts that have never logged on", u["never_logged_on"],
"Possibly unused/orphaned provisioning; verify before disabling"))
if u["admin_count_flagged"]:
findings.append(("Info", "Accounts with adminCount=1 (current or former privileged)", u["admin_count_flagged"],
"SDProp-protected ACLs persist even after privilege is removed; review membership"))
severity_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4}
findings.sort(key=lambda f: severity_order[f[0]])
lines = []
a = lines.append
a("## Executive Summary")
a("")
a(f"- Users: {u['total_users']} total ({u['enabled']} enabled, {u['disabled']} disabled)")
a(f"- Computers: {c['total']} total ({c['disabled']} disabled)")
a(f"- Groups: {g['total']} total ({g['security']} security, {g['distribution']} distribution)")
a(f"- Max OU nesting depth: {stats['max_ou_depth']}")
a("")
if findings:
a("### Risk & Cleanup Findings")
a("")
a("| Severity | Finding | Count | Notes |")
a("|---|---|---|---|")
for severity, finding, count, notes in findings:
a(f"| {severity} | {finding} | {count} | {notes} |")
a("")
else:
a("No notable risk or cleanup findings surfaced by this audit's checks.")
a("")
return "\n".join(lines)
def build_report(data, args):
ous = data["ous"]
users = data["users"]
@@ -264,6 +379,8 @@ def build_report(data, args):
groups = data["groups"]
obj_counts = data["object_counts"]
stats = compute_stats(data, args)
lines = []
a = lines.append
a(f"# Active Directory Audit Report")
@@ -274,6 +391,8 @@ def build_report(data, args):
a(f"- Stale-account threshold: {args.stale_days} days of inactivity")
a(f"")
a(build_executive_summary(stats, args))
a("## Object Type Counts")
a("")
a("| Object Class | Count |")
@@ -285,8 +404,7 @@ def build_report(data, args):
a("## Organizational Units")
a("")
a(f"- Total OUs: {len(ous)}")
max_depth = max((o["depth"] for o in ous), default=0)
a(f"- Maximum nesting depth: {max_depth}")
a(f"- Maximum nesting depth: {stats['max_ou_depth']}")
a("")
a("| OU DN | Depth | Description |")
a("|---|---|---|")
@@ -296,17 +414,16 @@ def build_report(data, args):
a("## Users")
a("")
total_users = len(users)
disabled = sum(1 for u in users if u["disabled"])
enabled = total_users - disabled
locked = sum(1 for u in users if u["locked"])
pwd_never_expires = sum(1 for u in users if u["pwd_never_expires"])
pwd_not_required = sum(1 for u in users if u["pwd_not_required"])
never_logged_on = sum(1 for u in users if u["never_logged_on"] and not u["disabled"])
stale = sum(1 for u in users if u["stale"])
admin_count_flagged = sum(1 for u in users if u["admin_count"])
trusted_deleg = sum(1 for u in users if u["trusted_for_delegation"])
no_preauth = sum(1 for u in users if u["kerberos_preauth_disabled"])
u = stats["users"]
total_users, enabled, disabled = u["total_users"], u["enabled"], u["disabled"]
locked = u["locked"]
pwd_never_expires = u["pwd_never_expires"]
pwd_not_required = u["pwd_not_required"]
never_logged_on = u["never_logged_on"]
stale = u["stale"]
admin_count_flagged = u["admin_count_flagged"]
trusted_deleg = u["trusted_deleg"]
no_preauth = u["no_preauth"]
a(f"- Total user objects: {total_users}")
a(f"- Enabled: {enabled}")
@@ -332,10 +449,9 @@ def build_report(data, args):
a("## Computers")
a("")
total_comp = len(computers)
comp_disabled = sum(1 for c in computers if c["disabled"])
comp_stale = sum(1 for c in computers if c["stale"])
os_counter = Counter(c["os"] or "Unknown" for c in computers)
c = stats["computers"]
total_comp, comp_disabled, comp_stale = c["total"], c["disabled"], c["stale"]
os_counter = c["os_counter"]
a(f"- Total computer objects: {total_comp}")
a(f"- Disabled: {comp_disabled}")
@@ -351,11 +467,10 @@ def build_report(data, args):
a("## Groups")
a("")
total_groups = len(groups)
security_groups = sum(1 for g in groups if g["type"] == "Security")
distribution_groups = total_groups - security_groups
empty_groups = sum(1 for g in groups if g["empty"])
scope_counter = Counter(g["scope"] for g in groups)
gs = stats["groups"]
total_groups, security_groups, distribution_groups = gs["total"], gs["security"], gs["distribution"]
empty_groups = gs["empty"]
scope_counter = gs["scope_counter"]
a(f"- Total groups: {total_groups}")
a(f"- Security groups: {security_groups}")