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
+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,9 +392,20 @@ 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.")
a("")
@@ -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)