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:
+60
-34
@@ -239,28 +239,76 @@ Add-Line "- Groups: $totalGroups total ($securityGroups security, $distributionG
|
|||||||
Add-Line "- Max OU nesting depth: $maxDepth"
|
Add-Line "- Max OU nesting depth: $maxDepth"
|
||||||
Add-Line ""
|
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]
|
$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 ($pwdNotRequired -gt 0) {
|
||||||
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")) }
|
$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 ($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 ($noPreauth -gt 0) {
|
||||||
if ($staleUsers -gt 0) { $findings.Add(@("Medium", "Stale enabled user accounts (>$StaleDays days inactive)", $staleUsers, "Candidates for disable/offboarding review")) }
|
$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 ($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 ($trustedDeleg -gt 0) {
|
||||||
if ($pwdNeverExpires -gt 0) { $findings.Add(@("Medium", "Accounts with password-never-expires set", $pwdNeverExpires, "Review against password policy; exempt only where justified")) }
|
$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 ($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 ($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 }
|
$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) {
|
if ($findings.Count -gt 0) {
|
||||||
Add-Line "### Risk & Cleanup Findings"
|
Add-Line "### Risk & Cleanup Findings"
|
||||||
Add-Line ""
|
Add-Line ""
|
||||||
Add-Line "| Severity | Finding | Count | Notes |"
|
Add-Line "| Severity | Finding | Count | Notes |"
|
||||||
Add-Line "|---|---|---|---|"
|
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 ""
|
||||||
|
|
||||||
|
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 {
|
} else {
|
||||||
Add-Line "No notable risk or cleanup findings surfaced by this audit's checks."
|
Add-Line "No notable risk or cleanup findings surfaced by this audit's checks."
|
||||||
Add-Line ""
|
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 "- Kerberos pre-auth disabled (AS-REP roastable): **$noPreauth**"
|
||||||
Add-Line ""
|
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 "## Computers"
|
||||||
Add-Line ""
|
Add-Line ""
|
||||||
Add-Line "- Total computer objects: $totalComp"
|
Add-Line "- Total computer objects: $totalComp"
|
||||||
@@ -345,17 +382,6 @@ foreach ($g in ($groups | Sort-Object -Property MemberCount -Descending | Select
|
|||||||
}
|
}
|
||||||
Add-Line ""
|
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"
|
$reportPath = Join-Path $OutDir "ad_audit_report_$ts.md"
|
||||||
$sb.ToString() | Out-File -FilePath $reportPath -Encoding utf8
|
$sb.ToString() | Out-File -FilePath $reportPath -Encoding utf8
|
||||||
|
|
||||||
|
|||||||
+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"]
|
u = stats["users"]
|
||||||
c = stats["computers"]
|
c = stats["computers"]
|
||||||
g = stats["groups"]
|
g = stats["groups"]
|
||||||
|
|
||||||
|
# Each finding: severity, title, notes, column labels, matching object rows.
|
||||||
findings = []
|
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"]:
|
if u["pwd_not_required"]:
|
||||||
findings.append(("Critical", "User accounts allowing blank passwords", u["pwd_not_required"],
|
findings.append(("Critical", "User accounts allowing blank passwords",
|
||||||
"PASSWD_NOTREQD flag set; remove unless there is a specific reason"))
|
"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"]:
|
if u["no_preauth"]:
|
||||||
findings.append(("Critical", "AS-REP roastable accounts (Kerberos pre-auth disabled)", u["no_preauth"],
|
findings.append(("Critical", "AS-REP roastable accounts (Kerberos pre-auth disabled)",
|
||||||
"Offline password cracking risk; re-enable pre-auth unless required"))
|
"Offline password cracking risk; re-enable pre-auth unless required",
|
||||||
|
USER_COLS, user_rows(lambda x: x["kerberos_preauth_disabled"])))
|
||||||
if u["trusted_deleg"]:
|
if u["trusted_deleg"]:
|
||||||
findings.append(("Critical", "Accounts trusted for unconstrained delegation", u["trusted_deleg"],
|
findings.append(("Critical", "Accounts trusted for unconstrained delegation",
|
||||||
"High-value targets for credential theft; move to constrained/no 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"]:
|
if u["locked"]:
|
||||||
findings.append(("High", "Currently locked-out user accounts", u["locked"],
|
findings.append(("High", "Currently locked-out user accounts",
|
||||||
"May indicate attack activity or stale service credentials"))
|
"May indicate attack activity or stale service credentials",
|
||||||
|
USER_COLS, user_rows(lambda x: x["locked"])))
|
||||||
if u["stale"]:
|
if u["stale"]:
|
||||||
findings.append(("Medium", f"Stale enabled user accounts (>{args.stale_days}d inactive)", u["stale"],
|
findings.append(("Medium", f"Stale enabled user accounts (>{args.stale_days}d inactive)",
|
||||||
"Candidates for disable/offboarding review"))
|
"Candidates for disable/offboarding review",
|
||||||
|
USER_COLS, user_rows(lambda x: x["stale"])))
|
||||||
if c["stale"]:
|
if c["stale"]:
|
||||||
findings.append(("Medium", f"Stale enabled computer accounts (>{args.stale_days}d inactive)", c["stale"],
|
findings.append(("Medium", f"Stale enabled computer accounts (>{args.stale_days}d inactive)",
|
||||||
"Likely decommissioned hardware still trusted in the domain"))
|
"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"]:
|
if g["empty"]:
|
||||||
findings.append(("Medium", "Empty security/distribution groups", g["empty"],
|
findings.append(("Medium", "Empty security/distribution groups",
|
||||||
"Cleanup candidates ahead of OU/group reorg"))
|
"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"]:
|
if u["pwd_never_expires"]:
|
||||||
findings.append(("Medium", "Accounts with password-never-expires set", u["pwd_never_expires"],
|
findings.append(("Medium", "Accounts with password-never-expires set",
|
||||||
"Review against password policy; exempt only where justified"))
|
"Review against password policy; exempt only where justified",
|
||||||
|
USER_COLS, user_rows(lambda x: x["pwd_never_expires"])))
|
||||||
if u["never_logged_on"]:
|
if u["never_logged_on"]:
|
||||||
findings.append(("Low", "Enabled accounts that have never logged on", u["never_logged_on"],
|
findings.append(("Low", "Enabled accounts that have never logged on",
|
||||||
"Possibly unused/orphaned provisioning; verify before disabling"))
|
"Possibly unused/orphaned provisioning; verify before disabling",
|
||||||
|
USER_COLS, user_rows(lambda x: x["never_logged_on"])))
|
||||||
if u["admin_count_flagged"]:
|
if u["admin_count_flagged"]:
|
||||||
findings.append(("Info", "Accounts with adminCount=1 (current or former privileged)", u["admin_count_flagged"],
|
findings.append(("Info", "Accounts with adminCount=1 (current or former privileged)",
|
||||||
"SDProp-protected ACLs persist even after privilege is removed; review membership"))
|
"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}
|
severity_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4}
|
||||||
findings.sort(key=lambda f: severity_order[f[0]])
|
findings.sort(key=lambda f: severity_order[f[0]])
|
||||||
@@ -362,9 +392,20 @@ def build_executive_summary(stats, args):
|
|||||||
a("")
|
a("")
|
||||||
a("| Severity | Finding | Count | Notes |")
|
a("| Severity | Finding | Count | Notes |")
|
||||||
a("|---|---|---|---|")
|
a("|---|---|---|---|")
|
||||||
for severity, finding, count, notes in findings:
|
for severity, title, notes, cols, rows in findings:
|
||||||
a(f"| {severity} | {finding} | {count} | {notes} |")
|
a(f"| {severity} | {title} | {len(rows)} | {notes} |")
|
||||||
a("")
|
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:
|
else:
|
||||||
a("No notable risk or cleanup findings surfaced by this audit's checks.")
|
a("No notable risk or cleanup findings surfaced by this audit's checks.")
|
||||||
a("")
|
a("")
|
||||||
@@ -391,7 +432,7 @@ def build_report(data, args):
|
|||||||
a(f"- Stale-account threshold: {args.stale_days} days of inactivity")
|
a(f"- Stale-account threshold: {args.stale_days} days of inactivity")
|
||||||
a(f"")
|
a(f"")
|
||||||
|
|
||||||
a(build_executive_summary(stats, args))
|
a(build_executive_summary(data, stats, args))
|
||||||
|
|
||||||
a("## Object Type Counts")
|
a("## Object Type Counts")
|
||||||
a("")
|
a("")
|
||||||
@@ -438,15 +479,6 @@ def build_report(data, args):
|
|||||||
a(f"- Kerberos pre-auth disabled (AS-REP roastable): **{no_preauth}**")
|
a(f"- Kerberos pre-auth disabled (AS-REP roastable): **{no_preauth}**")
|
||||||
a("")
|
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("## Computers")
|
||||||
a("")
|
a("")
|
||||||
c = stats["computers"]
|
c = stats["computers"]
|
||||||
@@ -492,15 +524,6 @@ def build_report(data, args):
|
|||||||
a(f"| {g['sam']} | {g['type']} | {g['scope']} | {g['member_count']} |")
|
a(f"| {g['sam']} | {g['type']} | {g['scope']} | {g['member_count']} |")
|
||||||
a("")
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user