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.
This commit is contained in:
2026-08-21 16:14:28 -04:00
commit 60cbfadbae
5 changed files with 772 additions and 0 deletions
+17
View File
@@ -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
View File
+1
View File
@@ -0,0 +1 @@
ldap3>=2.9
+325
View File
@@ -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"
+429
View File
@@ -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()