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:
+17
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ldap3>=2.9
|
||||||
@@ -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
@@ -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()
|
||||||
Reference in New Issue
Block a user