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:
+65
-42
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user