diff --git a/src/Invoke-ADAudit.ps1 b/src/Invoke-ADAudit.ps1 index 9c05c5f..f331e96 100644 --- a/src/Invoke-ADAudit.ps1 +++ b/src/Invoke-ADAudit.ps1 @@ -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 diff --git a/src/ad_audit.py b/src/ad_audit.py index 6a57b53..b5f7059 100644 --- a/src/ad_audit.py +++ b/src/ad_audit.py @@ -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)