Add per-object detail lists to each risk finding

Each finding in the Risk & Cleanup table now expands into a full list
of the matching accounts/computers/groups (sAMAccountName, last logon
or type, DN) so the report can be acted on directly instead of just
citing counts. Removed the now-redundant standalone stale-user and
empty-group sections since they're covered by the finding lists.
This commit is contained in:
2026-08-21 16:25:18 -04:00
parent 903f64a40b
commit 1a2a9b8d16
2 changed files with 125 additions and 76 deletions
+60 -34
View File
@@ -239,28 +239,76 @@ Add-Line "- Groups: $totalGroups total ($securityGroups security, $distributionG
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(@("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")) }
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[$_[0]] }
$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[0]) | $($f[1]) | $($f[2]) | $($f[3]) |" }
foreach ($f in $findings) { Add-Line "| $($f.Severity) | $($f.Title) | $($f.Rows.Count) | $($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 "| $($f.Cols[0]) | $($f.Cols[1]) | $($f.Cols[2]) |"
Add-Line "|---|---|---|"
foreach ($row in $f.Rows) { Add-Line "| $($row.Col0) | $($row.Col1) | $($row.DN) |" }
Add-Line ""
}
} else {
Add-Line "No notable risk or cleanup findings surfaced by this audit's checks."
Add-Line ""
@@ -298,17 +346,6 @@ Add-Line "- Trusted for unconstrained delegation: **$trustedDeleg**"
Add-Line "- Kerberos pre-auth disabled (AS-REP roastable): **$noPreauth**"
Add-Line ""
if ($staleUsers -gt 0) {
Add-Line "### Stale user accounts"
Add-Line ""
Add-Line "| sAMAccountName | Last Logon | DN |"
Add-Line "|---|---|---|"
foreach ($u in ($users | Where-Object Stale | Sort-Object LastLogon)) {
Add-Line "| $($u.SamAccountName) | $($u.LastLogon) | $($u.DN) |"
}
Add-Line ""
}
Add-Line "## Computers"
Add-Line ""
Add-Line "- Total computer objects: $totalComp"
@@ -345,17 +382,6 @@ foreach ($g in ($groups | Sort-Object -Property MemberCount -Descending | Select
}
Add-Line ""
if ($emptyGroups -gt 0) {
Add-Line "### Empty groups (candidates for cleanup)"
Add-Line ""
Add-Line "| Group | DN |"
Add-Line "|---|---|"
foreach ($g in ($groups | Where-Object Empty | Sort-Object SamAccountName)) {
Add-Line "| $($g.SamAccountName) | $($g.DN) |"
}
Add-Line ""
}
$reportPath = Join-Path $OutDir "ad_audit_report_$ts.md"
$sb.ToString() | Out-File -FilePath $reportPath -Encoding utf8
+65 -42
View File
@@ -308,42 +308,72 @@ def compute_stats(data, args):
}
def build_executive_summary(stats, args):
def build_executive_summary(data, stats, args):
users = data["users"]
computers = data["computers"]
groups = data["groups"]
u = stats["users"]
c = stats["computers"]
g = stats["groups"]
# Each finding: severity, title, notes, column labels, matching object rows.
findings = []
def user_rows(pred):
return [
(x["sam"], x["last_logon"] or "", x["dn"])
for x in sorted(
(x for x in users if pred(x)),
key=lambda x: x["sam"].lower(),
)
]
USER_COLS = ("Account", "Last Logon", "DN")
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"))
findings.append(("Critical", "User accounts allowing blank passwords",
"PASSWD_NOTREQD flag set; remove unless there is a specific reason",
USER_COLS, user_rows(lambda x: x["pwd_not_required"])))
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"))
findings.append(("Critical", "AS-REP roastable accounts (Kerberos pre-auth disabled)",
"Offline password cracking risk; re-enable pre-auth unless required",
USER_COLS, user_rows(lambda x: x["kerberos_preauth_disabled"])))
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"))
findings.append(("Critical", "Accounts trusted for unconstrained delegation",
"High-value targets for credential theft; move to constrained/no delegation",
USER_COLS, user_rows(lambda x: x["trusted_for_delegation"])))
if u["locked"]:
findings.append(("High", "Currently locked-out user accounts", u["locked"],
"May indicate attack activity or stale service credentials"))
findings.append(("High", "Currently locked-out user accounts",
"May indicate attack activity or stale service credentials",
USER_COLS, user_rows(lambda x: x["locked"])))
if u["stale"]:
findings.append(("Medium", f"Stale enabled user accounts (>{args.stale_days}d inactive)", u["stale"],
"Candidates for disable/offboarding review"))
findings.append(("Medium", f"Stale enabled user accounts (>{args.stale_days}d inactive)",
"Candidates for disable/offboarding review",
USER_COLS, user_rows(lambda x: x["stale"])))
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"))
findings.append(("Medium", f"Stale enabled computer accounts (>{args.stale_days}d inactive)",
"Likely decommissioned hardware still trusted in the domain",
("Computer", "Last Logon", "DN"),
[(x["sam"], x["last_logon"] or "", x["dn"])
for x in sorted((x for x in computers if x["stale"]), key=lambda x: x["sam"].lower())]))
if g["empty"]:
findings.append(("Medium", "Empty security/distribution groups", g["empty"],
"Cleanup candidates ahead of OU/group reorg"))
findings.append(("Medium", "Empty security/distribution groups",
"Cleanup candidates ahead of OU/group reorg",
("Group", "Type", "DN"),
[(x["sam"], x["type"], x["dn"])
for x in sorted((x for x in groups if x["empty"]), key=lambda x: x["sam"].lower())]))
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"))
findings.append(("Medium", "Accounts with password-never-expires set",
"Review against password policy; exempt only where justified",
USER_COLS, user_rows(lambda x: x["pwd_never_expires"])))
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"))
findings.append(("Low", "Enabled accounts that have never logged on",
"Possibly unused/orphaned provisioning; verify before disabling",
USER_COLS, user_rows(lambda x: x["never_logged_on"])))
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"))
findings.append(("Info", "Accounts with adminCount=1 (current or former privileged)",
"SDProp-protected ACLs persist even after privilege is removed; review membership",
USER_COLS, user_rows(lambda x: x["admin_count"])))
severity_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4}
findings.sort(key=lambda f: severity_order[f[0]])
@@ -362,8 +392,19 @@ def build_executive_summary(stats, args):
a("")
a("| Severity | Finding | Count | Notes |")
a("|---|---|---|---|")
for severity, finding, count, notes in findings:
a(f"| {severity} | {finding} | {count} | {notes} |")
for severity, title, notes, cols, rows in findings:
a(f"| {severity} | {title} | {len(rows)} | {notes} |")
a("")
a("### Finding Detail Lists")
a("")
for severity, title, notes, cols, rows in findings:
a(f"#### [{severity}] {title}")
a("")
a(f"| {cols[0]} | {cols[1]} | {cols[2]} |")
a("|---|---|---|")
for col0, col1, dn in rows:
a(f"| {col0} | {col1} | {dn} |")
a("")
else:
a("No notable risk or cleanup findings surfaced by this audit's checks.")
@@ -391,7 +432,7 @@ 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(build_executive_summary(data, stats, args))
a("## Object Type Counts")
a("")
@@ -438,15 +479,6 @@ def build_report(data, args):
a(f"- Kerberos pre-auth disabled (AS-REP roastable): **{no_preauth}**")
a("")
if stale:
a("### Stale user accounts")
a("")
a("| sAMAccountName | Last Logon | DN |")
a("|---|---|---|")
for u in sorted((u for u in users if u["stale"]), key=lambda x: x["last_logon"] or ""):
a(f"| {u['sam']} | {u['last_logon']} | {u['dn']} |")
a("")
a("## Computers")
a("")
c = stats["computers"]
@@ -492,15 +524,6 @@ def build_report(data, args):
a(f"| {g['sam']} | {g['type']} | {g['scope']} | {g['member_count']} |")
a("")
if empty_groups:
a("### Empty groups (candidates for cleanup)")
a("")
a("| Group | DN |")
a("|---|---|")
for g in sorted((g for g in groups if g["empty"]), key=lambda x: x["sam"]):
a(f"| {g['sam']} | {g['dn']} |")
a("")
return "\n".join(lines)