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:
+77
-37
@@ -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
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user