diff --git a/src/Invoke-ADAudit.ps1 b/src/Invoke-ADAudit.ps1 index 3231cff..9c05c5f 100644 --- a/src/Invoke-ADAudit.ps1 +++ b/src/Invoke-ADAudit.ps1 @@ -193,38 +193,11 @@ $raw | ConvertTo-Json -Depth 6 | Out-File -FilePath $rawPath -Encoding utf8 # Build Markdown report # --------------------------------------------------------------------------- Write-Host " - building report..." -$sb = New-Object System.Text.StringBuilder -function Add-Line([string]$text = "") { [void]$sb.AppendLine($text) } - -Add-Line "# Active Directory Audit Report" -Add-Line "" -Add-Line "- Search base: ``$SearchBase``" -if ($Server) { Add-Line "- Domain controller: ``$Server``" } -Add-Line "- Generated: $($now.ToUniversalTime().ToString("o"))" -Add-Line "- Stale-account threshold: $StaleDays days of inactivity" -Add-Line "" - -Add-Line "## Object Type Counts" -Add-Line "" -Add-Line "| Object Class | Count |" -Add-Line "|---|---|" -foreach ($row in $objectCounts) { Add-Line "| $($row.ObjectClass) | $($row.Count) |" } -Add-Line "" - -Add-Line "## Organizational Units" -Add-Line "" -Add-Line "- Total OUs: $($ous.Count)" +# Aggregate stats up front so the executive summary and the detailed +# sections below both draw from the same computed values. $maxDepth = ($ous | Measure-Object -Property Depth -Maximum).Maximum -Add-Line "- Maximum nesting depth: $maxDepth" -Add-Line "" -Add-Line "| OU DN | Depth | Description |" -Add-Line "|---|---|---|" -foreach ($o in ($ous | Sort-Object DN)) { Add-Line "| $($o.DN) | $($o.Depth) | $($o.Description) |" } -Add-Line "" -Add-Line "## Users" -Add-Line "" $totalUsers = $users.Count $disabled = ($users | Where-Object Disabled).Count $enabled = $totalUsers - $disabled @@ -237,6 +210,81 @@ $adminCountFlagged = ($users | Where-Object AdminCount).Count $trustedDeleg = ($users | Where-Object TrustedForDelegation).Count $noPreauth = ($users | Where-Object KerberosPreAuthDisabled).Count +$totalComp = $computers.Count +$compDisabled = ($computers | Where-Object Disabled).Count +$compStale = ($computers | Where-Object Stale).Count + +$totalGroups = $groups.Count +$securityGroups = ($groups | Where-Object { $_.Type -eq "Security" }).Count +$distributionGroups = $totalGroups - $securityGroups +$emptyGroups = ($groups | Where-Object Empty).Count + +$sb = New-Object System.Text.StringBuilder + +function Add-Line([string]$text = "") { [void]$sb.AppendLine($text) } + +Add-Line "# Active Directory Audit Report" +Add-Line "" +Add-Line "- Search base: ``$SearchBase``" +if ($Server) { Add-Line "- Domain controller: ``$Server``" } +Add-Line "- Generated: $($now.ToUniversalTime().ToString("o"))" +Add-Line "- Stale-account threshold: $StaleDays days of inactivity" +Add-Line "" + +Add-Line "## Executive Summary" +Add-Line "" +Add-Line "- Users: $totalUsers total ($enabled enabled, $disabled disabled)" +Add-Line "- Computers: $totalComp total ($compDisabled disabled)" +Add-Line "- Groups: $totalGroups total ($securityGroups security, $distributionGroups distribution)" +Add-Line "- Max OU nesting depth: $maxDepth" +Add-Line "" + +$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")) } + +$severityOrder = @{ "Critical" = 0; "High" = 1; "Medium" = 2; "Low" = 3; "Info" = 4 } +$findings = $findings | Sort-Object { $severityOrder[$_[0]] } + +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]) |" } + Add-Line "" +} else { + Add-Line "No notable risk or cleanup findings surfaced by this audit's checks." + Add-Line "" +} + +Add-Line "## Object Type Counts" +Add-Line "" +Add-Line "| Object Class | Count |" +Add-Line "|---|---|" +foreach ($row in $objectCounts) { Add-Line "| $($row.ObjectClass) | $($row.Count) |" } +Add-Line "" + +Add-Line "## Organizational Units" +Add-Line "" +Add-Line "- Total OUs: $($ous.Count)" +Add-Line "- Maximum nesting depth: $maxDepth" +Add-Line "" +Add-Line "| OU DN | Depth | Description |" +Add-Line "|---|---|---|" +foreach ($o in ($ous | Sort-Object DN)) { Add-Line "| $($o.DN) | $($o.Depth) | $($o.Description) |" } +Add-Line "" + +Add-Line "## Users" +Add-Line "" Add-Line "- Total user objects: $totalUsers" Add-Line "- Enabled: $enabled" Add-Line "- Disabled: $disabled" @@ -263,9 +311,6 @@ if ($staleUsers -gt 0) { Add-Line "## Computers" Add-Line "" -$totalComp = $computers.Count -$compDisabled = ($computers | Where-Object Disabled).Count -$compStale = ($computers | Where-Object Stale).Count Add-Line "- Total computer objects: $totalComp" Add-Line "- Disabled: $compDisabled" Add-Line "- Stale (enabled, inactive > $StaleDays days): $compStale" @@ -280,11 +325,6 @@ Add-Line "" Add-Line "## Groups" Add-Line "" -$totalGroups = $groups.Count -$securityGroups = ($groups | Where-Object { $_.Type -eq "Security" }).Count -$distributionGroups = $totalGroups - $securityGroups -$emptyGroups = ($groups | Where-Object Empty).Count - Add-Line "- Total groups: $totalGroups" Add-Line "- Security groups: $securityGroups" Add-Line "- Distribution groups: $distributionGroups" diff --git a/src/ad_audit.py b/src/ad_audit.py index 87da5c9..6a57b53 100644 --- a/src/ad_audit.py +++ b/src/ad_audit.py @@ -257,6 +257,121 @@ def audit_object_counts(conn, base_dn): return counter +def compute_stats(data, args): + ous = data["ous"] + users = data["users"] + computers = data["computers"] + groups = data["groups"] + + max_depth = max((o["depth"] for o in ous), default=0) + + total_users = len(users) + disabled = sum(1 for u in users if u["disabled"]) + stats = { + "total_users": total_users, + "enabled": total_users - disabled, + "disabled": disabled, + "locked": sum(1 for u in users if u["locked"]), + "pwd_never_expires": sum(1 for u in users if u["pwd_never_expires"]), + "pwd_not_required": sum(1 for u in users if u["pwd_not_required"]), + "never_logged_on": sum(1 for u in users if u["never_logged_on"] and not u["disabled"]), + "stale": sum(1 for u in users if u["stale"]), + "admin_count_flagged": sum(1 for u in users if u["admin_count"]), + "trusted_deleg": sum(1 for u in users if u["trusted_for_delegation"]), + "no_preauth": sum(1 for u in users if u["kerberos_preauth_disabled"]), + } + + total_comp = len(computers) + comp_disabled = sum(1 for c in computers if c["disabled"]) + comp_stats = { + "total": total_comp, + "disabled": comp_disabled, + "stale": sum(1 for c in computers if c["stale"]), + "os_counter": Counter(c["os"] or "Unknown" for c in computers), + } + + total_groups = len(groups) + security_groups = sum(1 for g in groups if g["type"] == "Security") + group_stats = { + "total": total_groups, + "security": security_groups, + "distribution": total_groups - security_groups, + "empty": sum(1 for g in groups if g["empty"]), + "scope_counter": Counter(g["scope"] for g in groups), + } + + return { + "max_ou_depth": max_depth, + "users": stats, + "computers": comp_stats, + "groups": group_stats, + } + + +def build_executive_summary(stats, args): + u = stats["users"] + c = stats["computers"] + g = stats["groups"] + + findings = [] + 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")) + 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")) + 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")) + if u["locked"]: + findings.append(("High", "Currently locked-out user accounts", u["locked"], + "May indicate attack activity or stale service credentials")) + if u["stale"]: + findings.append(("Medium", f"Stale enabled user accounts (>{args.stale_days}d inactive)", u["stale"], + "Candidates for disable/offboarding review")) + 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")) + if g["empty"]: + findings.append(("Medium", "Empty security/distribution groups", g["empty"], + "Cleanup candidates ahead of OU/group reorg")) + 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")) + 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")) + 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")) + + severity_order = {"Critical": 0, "High": 1, "Medium": 2, "Low": 3, "Info": 4} + findings.sort(key=lambda f: severity_order[f[0]]) + + lines = [] + a = lines.append + a("## Executive Summary") + a("") + a(f"- Users: {u['total_users']} total ({u['enabled']} enabled, {u['disabled']} disabled)") + a(f"- Computers: {c['total']} total ({c['disabled']} disabled)") + a(f"- Groups: {g['total']} total ({g['security']} security, {g['distribution']} distribution)") + a(f"- Max OU nesting depth: {stats['max_ou_depth']}") + a("") + if findings: + a("### Risk & Cleanup Findings") + a("") + a("| Severity | Finding | Count | Notes |") + a("|---|---|---|---|") + for severity, finding, count, notes in findings: + a(f"| {severity} | {finding} | {count} | {notes} |") + a("") + else: + a("No notable risk or cleanup findings surfaced by this audit's checks.") + a("") + + return "\n".join(lines) + + def build_report(data, args): ous = data["ous"] users = data["users"] @@ -264,6 +379,8 @@ def build_report(data, args): groups = data["groups"] obj_counts = data["object_counts"] + stats = compute_stats(data, args) + lines = [] a = lines.append a(f"# Active Directory Audit Report") @@ -274,6 +391,8 @@ 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("## Object Type Counts") a("") a("| Object Class | Count |") @@ -285,8 +404,7 @@ def build_report(data, args): a("## Organizational Units") a("") a(f"- Total OUs: {len(ous)}") - max_depth = max((o["depth"] for o in ous), default=0) - a(f"- Maximum nesting depth: {max_depth}") + a(f"- Maximum nesting depth: {stats['max_ou_depth']}") a("") a("| OU DN | Depth | Description |") a("|---|---|---|") @@ -296,17 +414,16 @@ def build_report(data, args): a("## Users") a("") - total_users = len(users) - disabled = sum(1 for u in users if u["disabled"]) - enabled = total_users - disabled - locked = sum(1 for u in users if u["locked"]) - pwd_never_expires = sum(1 for u in users if u["pwd_never_expires"]) - pwd_not_required = sum(1 for u in users if u["pwd_not_required"]) - never_logged_on = sum(1 for u in users if u["never_logged_on"] and not u["disabled"]) - stale = sum(1 for u in users if u["stale"]) - admin_count_flagged = sum(1 for u in users if u["admin_count"]) - trusted_deleg = sum(1 for u in users if u["trusted_for_delegation"]) - no_preauth = sum(1 for u in users if u["kerberos_preauth_disabled"]) + u = stats["users"] + total_users, enabled, disabled = u["total_users"], u["enabled"], u["disabled"] + locked = u["locked"] + pwd_never_expires = u["pwd_never_expires"] + pwd_not_required = u["pwd_not_required"] + never_logged_on = u["never_logged_on"] + stale = u["stale"] + admin_count_flagged = u["admin_count_flagged"] + trusted_deleg = u["trusted_deleg"] + no_preauth = u["no_preauth"] a(f"- Total user objects: {total_users}") a(f"- Enabled: {enabled}") @@ -332,10 +449,9 @@ def build_report(data, args): a("## Computers") a("") - total_comp = len(computers) - comp_disabled = sum(1 for c in computers if c["disabled"]) - comp_stale = sum(1 for c in computers if c["stale"]) - os_counter = Counter(c["os"] or "Unknown" for c in computers) + c = stats["computers"] + total_comp, comp_disabled, comp_stale = c["total"], c["disabled"], c["stale"] + os_counter = c["os_counter"] a(f"- Total computer objects: {total_comp}") a(f"- Disabled: {comp_disabled}") @@ -351,11 +467,10 @@ def build_report(data, args): a("## Groups") a("") - total_groups = len(groups) - security_groups = sum(1 for g in groups if g["type"] == "Security") - distribution_groups = total_groups - security_groups - empty_groups = sum(1 for g in groups if g["empty"]) - scope_counter = Counter(g["scope"] for g in groups) + gs = stats["groups"] + total_groups, security_groups, distribution_groups = gs["total"], gs["security"], gs["distribution"] + empty_groups = gs["empty"] + scope_counter = gs["scope_counter"] a(f"- Total groups: {total_groups}") a(f"- Security groups: {security_groups}")