commit 60cbfadbaefa06e5271815932fcaf34404cc48e3 Author: ergosteur Date: Fri Aug 21 16:14:28 2026 -0400 Add AD audit scripts (Python/LDAP and PowerShell/RSAT) Read-only structural/health audit of Active Directory: OU tree, user and computer account status/hygiene flags, and group breakdown. Two equivalent implementations depending on available access -- raw LDAP via ldap3, or Get-AD* cmdlets via RSAT on a domain-joined machine. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e47798c --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Audit output - contains real directory data, never commit +reports/ +*.json +*.csv + +# Local config/secrets +config/* +!config/.gitkeep + +# Python +__pycache__/ +*.pyc +.venv/ +venv/ + +# OS cruft +.DS_Store diff --git a/config/.gitkeep b/config/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9b5a6ef --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +ldap3>=2.9 diff --git a/src/Invoke-ADAudit.ps1 b/src/Invoke-ADAudit.ps1 new file mode 100644 index 0000000..3231cff --- /dev/null +++ b/src/Invoke-ADAudit.ps1 @@ -0,0 +1,325 @@ +<# +.SYNOPSIS + Read-only Active Directory structure/health audit using the RSAT ActiveDirectory module. + +.DESCRIPTION + Domain-joined-machine counterpart to ad_audit.py (the LDAP/ldap3 version). Produces the + same data points -- OUs, users, computers, groups, object type counts -- using + Get-AD* cmdlets instead of raw LDAP. Requires the ActiveDirectory PowerShell module + (RSAT) and only performs reads; no changes are made to the directory. + +.PARAMETER Server + Domain controller to query. Defaults to the domain of the current user's logon. + +.PARAMETER SearchBase + Distinguished name to scope the search to. Defaults to the domain root. + +.PARAMETER StaleDays + Days of inactivity (LastLogonDate) before an enabled account is flagged stale. Default 90. + +.PARAMETER Credential + Optional PSCredential to bind with. If omitted, uses the current logon session + (typical when run interactively on a domain-joined machine as a normal user). + +.PARAMETER OutDir + Directory to write the Markdown report and raw JSON into. Default .\reports relative + to the current working directory. + +.EXAMPLE + .\Invoke-ADAudit.ps1 + +.EXAMPLE + .\Invoke-ADAudit.ps1 -Server dc01.corp.example.com -SearchBase "OU=Corp,DC=corp,DC=example,DC=com" -StaleDays 120 + +.EXAMPLE + .\Invoke-ADAudit.ps1 -Credential (Get-Credential) +#> + +[CmdletBinding()] +param( + [string]$Server, + [string]$SearchBase, + [int]$StaleDays = 90, + [System.Management.Automation.PSCredential]$Credential, + [string]$OutDir = ".\reports" +) + +$ErrorActionPreference = "Stop" + +if (-not (Get-Module -ListAvailable -Name ActiveDirectory)) { + Write-Error "ActiveDirectory module not found. Install RSAT: Add-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0'" + exit 1 +} +Import-Module ActiveDirectory -ErrorAction Stop + +$adParams = @{} +if ($Server) { $adParams["Server"] = $Server } +if ($Credential) { $adParams["Credential"] = $Credential } + +if (-not $SearchBase) { + $domain = Get-ADDomain @adParams + $SearchBase = $domain.DistinguishedName +} + +Write-Host "Auditing base: $SearchBase" +if ($Server) { Write-Host "Domain controller: $Server" } + +# --- UserAccountControl bit flags --- +$UAC_PASSWD_NOTREQD = 0x0020 +$UAC_DONT_EXPIRE_PASSWD = 0x10000 +$UAC_SMARTCARD_REQUIRED = 0x40000 +$UAC_TRUSTED_FOR_DELEGATION = 0x80000 +$UAC_DONT_REQ_PREAUTH = 0x400000 + +$now = Get-Date +$staleCutoff = $now.AddDays(-$StaleDays) + +# --------------------------------------------------------------------------- +# Object type counts (whole subtree) +# --------------------------------------------------------------------------- +Write-Host " - object type counts..." +$allObjects = Get-ADObject -SearchBase $SearchBase -Filter * -Properties objectClass @adParams +$objectCounts = $allObjects | Group-Object -Property { $_.ObjectClass } | Sort-Object Count -Descending | + ForEach-Object { [PSCustomObject]@{ ObjectClass = $_.Name; Count = $_.Count } } + +# --------------------------------------------------------------------------- +# OUs +# --------------------------------------------------------------------------- +Write-Host " - organizational units..." +$ous = Get-ADOrganizationalUnit -SearchBase $SearchBase -Filter * -Properties Description, whenCreated @adParams | + ForEach-Object { + [PSCustomObject]@{ + DN = $_.DistinguishedName + Name = $_.Name + Description = $_.Description + Created = $_.whenCreated + Depth = ([regex]::Matches($_.DistinguishedName, "OU=")).Count + } + } + +# --------------------------------------------------------------------------- +# Users +# --------------------------------------------------------------------------- +Write-Host " - users..." +$userProps = @( + "sAMAccountName", "userPrincipalName", "userAccountControl", "LastLogonDate", + "PasswordLastSet", "whenCreated", "adminCount", "MemberOf", "Enabled", "LockedOut" +) +$users = Get-ADUser -SearchBase $SearchBase -Filter * -Properties $userProps @adParams | ForEach-Object { + $uac = [int]$_.userAccountControl + $lastLogon = $_.LastLogonDate + $neverLoggedOn = -not $lastLogon + $stale = ($_.Enabled) -and $lastLogon -and ($lastLogon -lt $staleCutoff) + + [PSCustomObject]@{ + DN = $_.DistinguishedName + SamAccountName = $_.sAMAccountName + UPN = $_.userPrincipalName + Disabled = -not $_.Enabled + Locked = [bool]$_.LockedOut + PwdNeverExpires = [bool]($uac -band $UAC_DONT_EXPIRE_PASSWD) + PwdNotRequired = [bool]($uac -band $UAC_PASSWD_NOTREQD) + SmartcardRequired = [bool]($uac -band $UAC_SMARTCARD_REQUIRED) + TrustedForDelegation = [bool]($uac -band $UAC_TRUSTED_FOR_DELEGATION) + KerberosPreAuthDisabled = [bool]($uac -band $UAC_DONT_REQ_PREAUTH) + AdminCount = [bool]($_.adminCount -gt 0) + LastLogon = $lastLogon + NeverLoggedOn = $neverLoggedOn -and $_.Enabled + Stale = $stale + PasswordLastSet = $_.PasswordLastSet + Created = $_.whenCreated + GroupCount = (@($_.MemberOf)).Count + } +} + +# --------------------------------------------------------------------------- +# Computers +# --------------------------------------------------------------------------- +Write-Host " - computers..." +$compProps = @("sAMAccountName", "userAccountControl", "LastLogonDate", "OperatingSystem", + "OperatingSystemVersion", "whenCreated", "Enabled") +$computers = Get-ADComputer -SearchBase $SearchBase -Filter * -Properties $compProps @adParams | ForEach-Object { + $lastLogon = $_.LastLogonDate + $stale = ($_.Enabled) -and $lastLogon -and ($lastLogon -lt $staleCutoff) + + [PSCustomObject]@{ + DN = $_.DistinguishedName + SamAccountName = $_.sAMAccountName + OS = $_.OperatingSystem + OSVersion = $_.OperatingSystemVersion + Disabled = -not $_.Enabled + LastLogon = $lastLogon + Stale = $stale + Created = $_.whenCreated + } +} + +# --------------------------------------------------------------------------- +# Groups +# --------------------------------------------------------------------------- +Write-Host " - groups..." +$groups = Get-ADGroup -SearchBase $SearchBase -Filter * -Properties Description, whenCreated, Members, GroupCategory, GroupScope @adParams | + ForEach-Object { + $memberCount = (@($_.Members)).Count + [PSCustomObject]@{ + DN = $_.DistinguishedName + SamAccountName = $_.SamAccountName + Type = $_.GroupCategory.ToString() + Scope = $_.GroupScope.ToString() + MemberCount = $memberCount + Empty = ($memberCount -eq 0) + Description = $_.Description + Created = $_.whenCreated + } + } + +# --------------------------------------------------------------------------- +# Write raw data +# --------------------------------------------------------------------------- +if (-not (Test-Path $OutDir)) { New-Item -ItemType Directory -Path $OutDir | Out-Null } +$ts = Get-Date -Format "yyyyMMdd_HHmmss" + +$raw = [PSCustomObject]@{ + ObjectCounts = $objectCounts + OUs = $ous + Users = $users + Computers = $computers + Groups = $groups +} +$rawPath = Join-Path $OutDir "ad_audit_raw_$ts.json" +$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)" +$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 +$locked = ($users | Where-Object Locked).Count +$pwdNeverExpires = ($users | Where-Object PwdNeverExpires).Count +$pwdNotRequired = ($users | Where-Object PwdNotRequired).Count +$neverLoggedOn = ($users | Where-Object NeverLoggedOn).Count +$staleUsers = ($users | Where-Object Stale).Count +$adminCountFlagged = ($users | Where-Object AdminCount).Count +$trustedDeleg = ($users | Where-Object TrustedForDelegation).Count +$noPreauth = ($users | Where-Object KerberosPreAuthDisabled).Count + +Add-Line "- Total user objects: $totalUsers" +Add-Line "- Enabled: $enabled" +Add-Line "- Disabled: $disabled" +Add-Line "- Currently locked out: $locked" +Add-Line "- Password never expires: $pwdNeverExpires" +Add-Line "- Password not required (blank password allowed): **$pwdNotRequired**" +Add-Line "- Enabled but never logged on: $neverLoggedOn" +Add-Line "- Stale (enabled, inactive > $StaleDays days): $staleUsers" +Add-Line "- adminCount=1 (protected/privileged, incl. historical): $adminCountFlagged" +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 "" +$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" +Add-Line "" +Add-Line "### OS breakdown" +Add-Line "" +Add-Line "| Operating System | Count |" +Add-Line "|---|---|" +$osGroups = $computers | Group-Object -Property { if ($_.OS) { $_.OS } else { "Unknown" } } | Sort-Object Count -Descending +foreach ($g in $osGroups) { Add-Line "| $($g.Name) | $($g.Count) |" } +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" +Add-Line "- Empty groups (0 members): $emptyGroups" +Add-Line "" +Add-Line "| Scope | Count |" +Add-Line "|---|---|" +$scopeGroups = $groups | Group-Object -Property Scope | Sort-Object Count -Descending +foreach ($g in $scopeGroups) { Add-Line "| $($g.Name) | $($g.Count) |" } +Add-Line "" + +Add-Line "### Largest groups (top 15 by member count)" +Add-Line "" +Add-Line "| Group | Type | Scope | Members |" +Add-Line "|---|---|---|---|" +foreach ($g in ($groups | Sort-Object -Property MemberCount -Descending | Select-Object -First 15)) { + Add-Line "| $($g.SamAccountName) | $($g.Type) | $($g.Scope) | $($g.MemberCount) |" +} +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 + +Write-Host "" +Write-Host "Done." +Write-Host " Raw data: $rawPath" +Write-Host " Report: $reportPath" diff --git a/src/ad_audit.py b/src/ad_audit.py new file mode 100644 index 0000000..87da5c9 --- /dev/null +++ b/src/ad_audit.py @@ -0,0 +1,429 @@ +#!/usr/bin/env python3 +"""Read-only LDAP audit of an Active Directory domain. + +Connects with a basic (non-admin) bind account and enumerates OUs, groups, +users, and computers to produce a structural/health report. Designed to work +with whatever a standard authenticated-user account can see over LDAP -- no +elevated rights required. +""" + +import argparse +import datetime +import getpass +import json +import sys +from collections import Counter, defaultdict + +from ldap3 import ALL, SUBTREE, Connection, Server, Tls +import ssl + +# --- UserAccountControl bit flags (subset relevant to an audit) --- +UAC_ACCOUNTDISABLE = 0x0002 +UAC_LOCKOUT = 0x0010 +UAC_PASSWD_NOTREQD = 0x0020 +UAC_DONT_EXPIRE_PASSWD = 0x10000 +UAC_SMARTCARD_REQUIRED = 0x40000 +UAC_TRUSTED_FOR_DELEGATION = 0x80000 +UAC_NOT_DELEGATED = 0x100000 +UAC_DONT_REQ_PREAUTH = 0x400000 + +FILETIME_EPOCH = datetime.datetime(1601, 1, 1) + + +def filetime_to_datetime(value): + try: + v = int(value) + except (TypeError, ValueError): + return None + if v == 0 or v == 0x7FFFFFFFFFFFFFFF: + return None + try: + return FILETIME_EPOCH + datetime.timedelta(microseconds=v / 10) + except OverflowError: + return None + + +def parse_args(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument("--host", required=True, help="DC hostname or IP") + p.add_argument("--port", type=int, default=None, help="LDAP port (default 389, or 636 with --ssl)") + p.add_argument("--base-dn", required=True, help="Search base, e.g. DC=corp,DC=example,DC=com") + p.add_argument("--bind-dn", default=None, help="Bind DN (prompted if omitted)") + p.add_argument("--ssl", action="store_true", help="Use LDAPS (implicit TLS)") + p.add_argument("--starttls", action="store_true", help="Use StartTLS over plain LDAP port") + p.add_argument("--no-verify-cert", action="store_true", help="Skip TLS cert verification (self-signed DCs)") + p.add_argument("--stale-days", type=int, default=90, help="Days of inactivity to flag an account as stale") + p.add_argument("--out-dir", default="reports", help="Directory to write report + raw JSON into") + return p.parse_args() + + +def connect(args): + tls = None + if args.ssl or args.starttls: + validate = ssl.CERT_NONE if args.no_verify_cert else ssl.CERT_REQUIRED + tls = Tls(validate=validate) + + port = args.port or (636 if args.ssl else 389) + server = Server(args.host, port=port, use_ssl=args.ssl, tls=tls, get_info=ALL) + + bind_dn = args.bind_dn or input("Bind DN (e.g. user@corp.example.com or DOMAIN\\user): ").strip() + password = getpass.getpass(f"Password for {bind_dn}: ") + + conn = Connection(server, user=bind_dn, password=password, auto_bind=False) + if args.starttls: + conn.open() + conn.start_tls() + if not conn.bind(): + print(f"Bind failed: {conn.result}", file=sys.stderr) + sys.exit(1) + return conn + + +def paged_search(conn, base_dn, search_filter, attributes): + entries = [] + conn.search( + search_base=base_dn, + search_filter=search_filter, + search_scope=SUBTREE, + attributes=attributes, + paged_size=1000, + ) + entries.extend(conn.entries) + cookie = conn.result.get("controls", {}).get("1.2.840.113556.1.4.319", {}).get("value", {}).get("cookie") + while cookie: + conn.search( + search_base=base_dn, + search_filter=search_filter, + search_scope=SUBTREE, + attributes=attributes, + paged_size=1000, + paged_cookie=cookie, + ) + entries.extend(conn.entries) + cookie = conn.result.get("controls", {}).get("1.2.840.113556.1.4.319", {}).get("value", {}).get("cookie") + return entries + + +def audit_ous(conn, base_dn): + attrs = ["distinguishedName", "ou", "description", "whenCreated"] + entries = paged_search(conn, base_dn, "(objectClass=organizationalUnit)", attrs) + ous = [] + for e in entries: + ous.append( + { + "dn": str(e.distinguishedName), + "name": str(e.ou) if e.ou else "", + "description": str(e.description) if e.description else "", + "created": str(e.whenCreated) if e.whenCreated else "", + "depth": str(e.distinguishedName).count("OU="), + } + ) + return ous + + +def audit_users(conn, base_dn, stale_days): + attrs = [ + "distinguishedName", + "sAMAccountName", + "userAccountControl", + "lastLogonTimestamp", + "pwdLastSet", + "whenCreated", + "adminCount", + "memberOf", + "userPrincipalName", + ] + entries = paged_search(conn, base_dn, "(&(objectCategory=person)(objectClass=user))", attrs) + + now = datetime.datetime.utcnow() + stale_cutoff = now - datetime.timedelta(days=stale_days) + + users = [] + for e in entries: + uac = int(str(e.userAccountControl)) if e.userAccountControl else 0 + last_logon = filetime_to_datetime(str(e.lastLogonTimestamp)) if e.lastLogonTimestamp else None + pwd_last_set = filetime_to_datetime(str(e.pwdLastSet)) if e.pwdLastSet else None + + disabled = bool(uac & UAC_ACCOUNTDISABLE) + pwd_never_expires = bool(uac & UAC_DONT_EXPIRE_PASSWD) + pwd_not_required = bool(uac & UAC_PASSWD_NOTREQD) + never_logged_on = last_logon is None + stale = (not disabled) and (last_logon is not None) and (last_logon < stale_cutoff) + + users.append( + { + "dn": str(e.distinguishedName), + "sam": str(e.sAMAccountName) if e.sAMAccountName else "", + "upn": str(e.userPrincipalName) if e.userPrincipalName else "", + "disabled": disabled, + "locked": bool(uac & UAC_LOCKOUT), + "pwd_never_expires": pwd_never_expires, + "pwd_not_required": pwd_not_required, + "smartcard_required": bool(uac & UAC_SMARTCARD_REQUIRED), + "trusted_for_delegation": bool(uac & UAC_TRUSTED_FOR_DELEGATION), + "kerberos_preauth_disabled": bool(uac & UAC_DONT_REQ_PREAUTH), + "admin_count": bool(e.adminCount and int(str(e.adminCount)) > 0), + "last_logon": last_logon.isoformat() if last_logon else None, + "never_logged_on": never_logged_on, + "stale": stale, + "pwd_last_set": pwd_last_set.isoformat() if pwd_last_set else None, + "created": str(e.whenCreated) if e.whenCreated else "", + "group_count": len(e.memberOf.values) if e.memberOf else 0, + } + ) + return users + + +def audit_computers(conn, base_dn, stale_days): + attrs = [ + "distinguishedName", + "sAMAccountName", + "userAccountControl", + "operatingSystem", + "operatingSystemVersion", + "lastLogonTimestamp", + "whenCreated", + ] + entries = paged_search(conn, base_dn, "(objectCategory=computer)", attrs) + + now = datetime.datetime.utcnow() + stale_cutoff = now - datetime.timedelta(days=stale_days) + + computers = [] + for e in entries: + uac = int(str(e.userAccountControl)) if e.userAccountControl else 0 + last_logon = filetime_to_datetime(str(e.lastLogonTimestamp)) if e.lastLogonTimestamp else None + disabled = bool(uac & UAC_ACCOUNTDISABLE) + stale = (not disabled) and (last_logon is not None) and (last_logon < stale_cutoff) + + computers.append( + { + "dn": str(e.distinguishedName), + "sam": str(e.sAMAccountName) if e.sAMAccountName else "", + "os": str(e.operatingSystem) if e.operatingSystem else "", + "os_version": str(e.operatingSystemVersion) if e.operatingSystemVersion else "", + "disabled": disabled, + "last_logon": last_logon.isoformat() if last_logon else None, + "stale": stale, + "created": str(e.whenCreated) if e.whenCreated else "", + } + ) + return computers + + +def audit_groups(conn, base_dn): + attrs = ["distinguishedName", "sAMAccountName", "groupType", "member", "description", "whenCreated"] + entries = paged_search(conn, base_dn, "(objectClass=group)", attrs) + + groups = [] + for e in entries: + gt = int(str(e.groupType)) if e.groupType else 0 + is_security = bool(gt & 0x80000000) + scope_bit = gt & 0x0C + if gt & 0x00000002: + scope = "DomainLocal" + elif gt & 0x00000004: + scope = "Global" + elif gt & 0x00000008: + scope = "Universal" + else: + scope = "Unknown" + + members = e.member.values if e.member else [] + groups.append( + { + "dn": str(e.distinguishedName), + "sam": str(e.sAMAccountName) if e.sAMAccountName else "", + "type": "Security" if is_security else "Distribution", + "scope": scope, + "member_count": len(members), + "empty": len(members) == 0, + "description": str(e.description) if e.description else "", + "created": str(e.whenCreated) if e.whenCreated else "", + } + ) + return groups + + +def audit_object_counts(conn, base_dn): + attrs = ["objectClass"] + entries = paged_search(conn, base_dn, "(objectClass=*)", attrs) + counter = Counter() + for e in entries: + classes = e.objectClass.values if e.objectClass else [] + # most-specific class is typically the last in the chain + leaf = classes[-1] if classes else "unknown" + counter[leaf] += 1 + return counter + + +def build_report(data, args): + ous = data["ous"] + users = data["users"] + computers = data["computers"] + groups = data["groups"] + obj_counts = data["object_counts"] + + lines = [] + a = lines.append + a(f"# Active Directory Audit Report") + a(f"") + a(f"- Base DN: `{args.base_dn}`") + a(f"- Domain Controller: `{args.host}`") + a(f"- Generated: {datetime.datetime.utcnow().isoformat()}Z") + a(f"- Stale-account threshold: {args.stale_days} days of inactivity") + a(f"") + + a("## Object Type Counts") + a("") + a("| Object Class | Count |") + a("|---|---|") + for cls, count in sorted(obj_counts.items(), key=lambda kv: -kv[1]): + a(f"| {cls} | {count} |") + a("") + + 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("") + a("| OU DN | Depth | Description |") + a("|---|---|---|") + for o in sorted(ous, key=lambda x: x["dn"]): + a(f"| {o['dn']} | {o['depth']} | {o['description']} |") + a("") + + 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"]) + + a(f"- Total user objects: {total_users}") + a(f"- Enabled: {enabled}") + a(f"- Disabled: {disabled}") + a(f"- Currently locked out: {locked}") + a(f"- Password never expires: {pwd_never_expires}") + a(f"- Password not required (blank password allowed): **{pwd_not_required}**") + a(f"- Enabled but never logged on: {never_logged_on}") + a(f"- Stale (enabled, inactive > {args.stale_days}d): {stale}") + a(f"- adminCount=1 (protected/privileged, incl. historical): {admin_count_flagged}") + a(f"- Trusted for unconstrained delegation: **{trusted_deleg}**") + 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("") + 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) + + a(f"- Total computer objects: {total_comp}") + a(f"- Disabled: {comp_disabled}") + a(f"- Stale (enabled, inactive > {args.stale_days}d): {comp_stale}") + a("") + a("### OS breakdown") + a("") + a("| Operating System | Count |") + a("|---|---|") + for os_name, count in sorted(os_counter.items(), key=lambda kv: -kv[1]): + a(f"| {os_name} | {count} |") + a("") + + 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) + + a(f"- Total groups: {total_groups}") + a(f"- Security groups: {security_groups}") + a(f"- Distribution groups: {distribution_groups}") + a(f"- Empty groups (0 members): {empty_groups}") + a("") + a("| Scope | Count |") + a("|---|---|") + for scope, count in scope_counter.most_common(): + a(f"| {scope} | {count} |") + a("") + + largest = sorted(groups, key=lambda g: -g["member_count"])[:15] + a("### Largest groups (top 15 by member count)") + a("") + a("| Group | Type | Scope | Members |") + a("|---|---|---|---|") + for g in largest: + 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) + + +def main(): + args = parse_args() + conn = connect(args) + print("Bound successfully. Enumerating directory (this may take a while on large domains)...") + + data = {} + print(" - object type counts...") + data["object_counts"] = dict(audit_object_counts(conn, args.base_dn)) + print(" - organizational units...") + data["ous"] = audit_ous(conn, args.base_dn) + print(" - users...") + data["users"] = audit_users(conn, args.base_dn, args.stale_days) + print(" - computers...") + data["computers"] = audit_computers(conn, args.base_dn, args.stale_days) + print(" - groups...") + data["groups"] = audit_groups(conn, args.base_dn) + + conn.unbind() + + import os + + os.makedirs(args.out_dir, exist_ok=True) + ts = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S") + + raw_path = os.path.join(args.out_dir, f"ad_audit_raw_{ts}.json") + with open(raw_path, "w") as f: + json.dump(data, f, indent=2, default=str) + + report = build_report(data, args) + report_path = os.path.join(args.out_dir, f"ad_audit_report_{ts}.md") + with open(report_path, "w") as f: + f.write(report) + + print(f"\nDone.\n Raw data: {raw_path}\n Report: {report_path}") + + +if __name__ == "__main__": + main()