Files
ergosteur ff9b15933d Final review pass: fix STARTTLS fallback, PS null-count bugs, badge scoping; add README
Ran a full review pass over both audit scripts and the HTML converter,
independently cross-checked against an automated code review, and fixed
everything that survived verification:

- ad_audit.py: connect() ignored the boolean return of conn.start_tls().
  ldap3 doesn't raise on STARTTLS failure by default, so a rejected/
  downgraded STARTTLS silently fell through to an unencrypted bind --
  the bind DN and password would go out in cleartext with no warning.
  Now aborts before calling bind() if start_tls() returns False.
- ad_audit.py: "Enabled accounts that have never logged on" detail rows
  weren't filtering out disabled accounts, unlike the count used to
  decide whether to show the finding at all -- so a disabled account
  could appear in a list titled "enabled accounts."
- ad_audit.py: replaced deprecated datetime.datetime.utcnow() (scheduled
  for removal) with timezone-aware datetime.now(timezone.utc); made
  FILETIME_EPOCH aware too so the stale-account comparison keeps working
  without a naive/aware TypeError.
- ad_audit.py: removed dead code (unused defaultdict import, unused
  UAC_NOT_DELEGATED constant, unused scope_bit variable, import os
  buried inside main() instead of at module level).
- Invoke-ADAudit.ps1: GroupCount/MemberCount used (@($x)).Count, which
  PowerShell evaluates to 1 (not 0) when $x is $null -- @($null) is a
  one-element array, not an empty one. This silently broke "empty group"
  detection (the report's own headline feature) for any group with
  genuinely zero members, and inflated GroupCount for users with no
  group memberships. Added Get-SafeCount to null-check before counting.
- Invoke-ADAudit.ps1: $maxDepth came back as $null (not 0) when a
  SearchBase had zero OUs, printing a blank instead of "0" in the report.
- Invoke-ADAudit.ps1 / ad_audit.py: the h4 finding-detail title wasn't
  escaped through ConvertTo-MdSafe/md_escape in one of its three
  occurrences, inconsistent with the other two -- fixed for consistency
  even though today's titles are all fixed strings.
- md_to_html.py: badge_findings_table()'s regex matched any single-word
  table cell equal to a severity name, not just the Severity column of
  the findings table -- an OS name, group name, or object class that
  happened to literally be "Critical"/"High"/etc (plausible under a
  tiering naming scheme) would get rewritten into a colored badge
  anywhere in the report. Rescoped to the one table whose header row is
  literally "Severity | Finding | Count | Notes", which only this
  generator ever emits.
- Added README.md covering both scripts' usage, what each collects, and
  a documented known limitation (finding-detection logic is duplicated
  between the Python and PowerShell implementations with no shared
  source of truth).

Every fix verified with a reproduction before and after: a mocked
start_tls() failure confirms bind() is never reached; mocked $null
MemberOf/Members confirm counts are now 0; a zero-OU domain confirms
"Maximum nesting depth: 0" instead of blank; a disabled never-logged-on
account confirms it's excluded from the enabled-accounts finding; and
AD data literally named "Critical"/"High" confirms it no longer gets
badged outside the real findings table.
2026-08-27 14:33:20 -04:00

111 lines
4.2 KiB
Markdown

# ad-probe
Read-only Active Directory structure/health audit. Two equivalent
implementations depending on what access you have to the target domain,
plus a converter for turning the report into a shareable HTML page.
- **`src/ad_audit.py`** — queries over raw LDAP (`ldap3`) with a basic,
non-admin bind account. Works from any Linux/macOS/Windows box with
network access to a DC; no RSAT or domain membership required.
- **`src/Invoke-ADAudit.ps1`** — the same audit using the `ActiveDirectory`
PowerShell module (RSAT). Run it on a domain-joined machine, typically
under your own logon.
- **`src/md_to_html.py`** — renders either script's Markdown report as a
single self-contained, styled HTML file (sortable tables, sidebar TOC,
local-time toggle, severity-coded findings).
Neither audit script writes to the directory or requires elevated rights —
they only read what a standard authenticated user/account can already see.
## What it collects
- **Object type counts** across the whole subtree
- **OUs** — full list, nesting depth
- **Users** — enabled/disabled, locked out, password-never-expires,
password-not-required (blank password allowed), never-logged-on, stale
(configurable inactivity threshold), `adminCount=1`, unconstrained
delegation, AS-REP roastable (Kerberos pre-auth disabled)
- **Computers** — enabled/disabled, stale, OS breakdown
- **Groups** — security vs. distribution, scope (domain-local/global/
universal), member counts, empty groups, largest groups
Each report opens with an **Executive Summary**: headline counts plus a
severity-ranked (Critical → Info) **Risk & Cleanup Findings** table, with
every finding expanding into the actual list of matching accounts/
computers/groups — not just a count — so it's actionable straight out of
the box.
## Usage
### Python / LDAP
```bash
pip install -r requirements.txt
python3 src/ad_audit.py \
--host dc01.corp.example.com \
--base-dn "DC=corp,DC=example,DC=com" \
[--ssl | --starttls] \
[--stale-days 90] \
[--out-dir reports]
```
You'll be prompted for a bind DN and password at runtime; nothing is
stored on disk. Pass `--no-verify-cert` for a DC with a self-signed
certificate. Writes `ad_audit_report_<timestamp>.md` and
`ad_audit_raw_<timestamp>.json` to `--out-dir` (default `reports/`).
### PowerShell / RSAT
```powershell
.\src\Invoke-ADAudit.ps1
```
Runs under your current logon by default. Optional parameters:
```powershell
.\src\Invoke-ADAudit.ps1 `
-Server dc01.corp.example.com `
-SearchBase "OU=Corp,DC=corp,DC=example,DC=com" `
-StaleDays 120 `
-Credential (Get-Credential) `
-OutDir .\reports
```
Requires the `ActiveDirectory` module (RSAT):
`Add-WindowsCapability -Online -Name 'Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0'`
### HTML report
```bash
python3 src/md_to_html.py # newest report in reports/
python3 src/md_to_html.py reports/ad_audit_report_....md -o out.html
```
Produces one `.html` file with no external dependencies — click any table
header to sort, use the sidebar to jump to a section or an individual
finding, and toggle timestamps between UTC and your local offset (both
RFC 3339 either way).
## Notes
- All timestamps in every report (Markdown, JSON, HTML) are RFC 3339 UTC
(`YYYY-MM-DDTHH:MM:SSZ`).
- `reports/` is gitignored — audit output contains real directory data and
should never be committed.
- AD attribute values (descriptions, sAMAccountName, OS strings, DNs) are
directory content that could be attacker-influenced, not
report-generated text. Both audit scripts escape them before they reach
a Markdown table cell, and `md_to_html.py` independently neutralizes any
raw HTML in its input as defense-in-depth — so a stray `<script>` in an
OU description renders as inert text, not a live tag.
## Known limitation
The Risk & Cleanup Findings logic (which conditions count as a finding,
their thresholds, titles, and severities) is implemented twice — once in
`ad_audit.py`, once in `Invoke-ADAudit.ps1` — since they're two different
languages with no shared runtime. There's no single source of truth, so a
fix or new finding added to one script has to be hand-ported to the other;
check both when changing what counts as a finding.