Repository navigation
Releases
ad-doctor 0.2.6
Forest scanning.
collect --forest -o DIRandscan --forest -o DIRfind the forest's domains from thecrossRefobjects and scan them one at a time, writing one ordinary snapshot per domain and aforest.jsonmanifest.rollup --forest DIRshows the per-domain results, the domains that could not be read, and who holds Enterprise Admins and Schema Admins across domains.- A missing domain is never a clean result. A domain that cannot be read is listed as not evaluated with the reason, and the run exits 3, even when
--fail-onalso matched. - Without
--forest, nothing changes.
Where your credentials go. A forest scan binds to domain controllers named in the Configuration partition, which is more than the one host you named. Before any bind, a name must be a plain DNS name inside its own domain; --forest-allow-dns SUFFIX replaces that rule for disjoint namespaces. Forest connections do not follow LDAP referrals, and a refused bind stops at the first DC of that domain to avoid lockouts. Without --forest-ca-file PEM, --ldaps does not authenticate the servers it finds. See docs/forest.md.
What is not joined. Only those two groups are followed across domains. Control paths, findings and scores stay per domain, so a path that crosses a domain boundary is not shown.
How far this is tested. The lab has one domain: there, scan --forest gives the same findings as a normal scan. Child domains, several trees, choosing and binding another domain's DC, and following members across domains have run only against fake forests and scripted LDAP servers, not against a real multi-domain forest. Reports from real forests are especially welcome.
Install:
uv tool install ad-doctorFull list of changes: CHANGELOG.md
ad-doctor 0.2.5
Every check now says whether it could judge what it covers, you can declare your own tier 0, and 27 new checks (125 to 152).
Check status. Each check is judged, not applicable, or not evaluated with the reason (data unreadable, older snapshot, switched off). It is in the JSON report, text, Markdown, SARIF, JUnit and the viewer. A check that was not evaluated never counts as passed, and the score is labelled a minimum when checks were held back.
The JSON report schema goes from 2 to 3, with new check_status and coverage keys.
Declared tier 0. --tier0-file and --tier0 add your own assets on analyze, scan, paths, view, collect, whatif and plan. Built-in tier 0 is never altered, and paths to a declared asset are marked. An asset whose owner and DACL are not in the snapshot is reported as not evaluated.
New checks
- Tier-0 logon restrictions per OU, authentication policy silos, and recent tier-0 changes from replication metadata (members added, AdminSDHolder's ACL, sIDHistory)
- Configuration Manager, AD FS DKM, Entra Cloud Sync and connector accounts
- Schema, Configuration and OU extended-right ACLs
- Scripts and tasks run by Group Policy
- SMB signing on tier-0 member servers
- RID 500 use, smart-card account hash age, mail on tier-0 accounts, group loops
- Weak CA certificates, display specifiers, dynamic objects
- Patch level of DCs and CAs against a dated table (
patches.toml), a version match only, with--no-patch-levelto turn it off
New options and commands
--dc-probes(off by default): WebClient, SMBv1 and LDAPS TLS versions on domain controllers--preflight FILE: a read-only script to run before the first 10 fixesad-doctor rollup: one view over the JSON reports of several domainsad-doctor service-accountsandad-doctor tier-scorecard
Also changed: on a snapshot whose control graph is incomplete, such as one from an older version, plan writes no [expect], and whatif --check fails any limit written by hand.
How far this is tested. Most positive cases of the new checks are unit tests, because the lab domain does not contain them and was not changed to. Reports from real domains are welcome. The patch table is maintained by hand and says when it is old.
Install:
uv tool install ad-doctorFull list of changes: CHANGELOG.md
ad-doctor 0.2.4
What-if in the browser, and seven checks on Active Directory-integrated DNS (118 to 125 checks).
Interactive what-if. With the full graph embedded, the What-if screen of ad-doctor view lets you turn each change on and off. Exposure, holders, routes and the paths closed, rerouted, open and new follow live, computed in the page on the same model as whatif and checked against it. Add changes from the steps and chokepoints on Control paths, compare with the plan in the file, and download the selection as a plan.toml that whatif --plan accepts.
- Changes that need a rebuilt graph are unavailable, with the reason, and get no number.
- Findings are not rechecked in the browser, and the page says so.
- Above the full-graph limit (50,000 directory objects) there is no interactive what-if.
New DNS checks
| Check | Finds |
|---|---|
| AD-DNS-001 | Zones that accept nonsecure dynamic updates |
| AD-DNS-002 | Write control over a zone outside tier 0 and DnsAdmins |
| AD-DNS-003 | Broad groups that can add records (the default, info only) |
| AD-DNS-004 | Names carrying marshaled target information, used for DNS-based coercion |
| AD-DNS-005 | Wildcard records |
| AD-DNS-006 | wpad, isatap or autodiscover owned by an ordinary account |
| AD-DNS-007 | SPN hosts with no DNS record, in zones where a low-privileged user can add one |
No zone transfer is attempted. An unreadable zone or partition is reported as not evaluated, not as passing, and older snapshots say the same.
How far this is tested. On the lab only AD-DNS-003 fires, which is the default state. The positive cases of the other six checks are unit tests and have not fired on a live domain. Reports from real domains are welcome.
Install:
uv tool install ad-doctorFull list of changes: CHANGELOG.md
ad-doctor 0.2.3
ad-doctor now reads what Group Policy sets in the registry, with Group Policy precedence. Nine new checks, 109 to 118.
Reader. Machine\Registry.pol, audit.csv and Preferences\Registry\Registry.xml from SYSVOL, resolved per computer by link order, Enforced, Block Inheritance and security filtering. Where the winner cannot be decided, for example a WMI filter, the value stays undecided with the reason instead of being assumed.
New checks
| Check | Finds |
|---|---|
| AD-GPO-009 | LLMNR not turned off by policy |
| AD-GPO-010 | WSUS over HTTP |
| AD-GPO-011 | PowerShell script block or module logging not set |
| AD-GPO-012 | UNC hardened paths missing or weak |
| AD-GPO-013 | Domain controller audit policy against Microsoft's baseline |
| AD-GPO-014 | DefaultPassword set through Group Policy |
| AD-GPO-015 | BlockNtlmv1SSO turned off |
| AD-GPO-016 | Weak DefaultDomainSupportedEncTypes on domain controllers |
| AD-ADCS-020 | ESC10: weak certificate mapping as far as Group Policy shows it |
What these checks can and cannot see. Group Policy not setting a value says nothing about the host's own registry, so most "not set" results are low or info and worded that way. ESC10 is reported as unknown when no GPO sets the values, because they are commonly set locally. Secret values never leave the parser: only the fact and the location are kept.
How far this is tested. The lab's GPOs carry no Registry.pol data, so the parser has run only on bytes built from Microsoft's format description, and the positive cases are unit tests. Reports from real domains are welcome. Microsoft's documents disagree on the order of two links in the same OU; the code follows New-GPLink and the Group Policy scope documentation, and this matters only when two such links set different values.
Older snapshots still load; these checks report as not evaluated.
Install:
uv tool install ad-doctorFull list of changes: CHANGELOG.md
ad-doctor 0.2.2
Ten new checks and new kinds of edge in the control graph. Control-path results can change: a domain with delegation or SID history paths may show more paths than it did on 0.2.1.
New edges in the control graph (paths, blast radius, plan, whatif, the viewer and the OpenGraph export)
- Constrained delegation with protocol transition
- Resource-based constrained delegation
- SID history
- ESC13, and ESC6 together with ESC16
An edge is drawn only when the attack works on the data the snapshot holds. ESC9, ESC15 and ESC6 without ESC16 stay findings rather than edges, because they depend on patch state the snapshot does not have. With several enterprise CAs no ESC6+ESC16 edge is drawn. A plan says "proven minimum" only when the model is exact; where flags shared between gates make it inexact, it gives a lower bound instead.
New checks (99 to 109)
| Check | Finds |
|---|---|
| AD-ACC-014 | Stale enabled computers |
| AD-ACC-015 | gMSAs with a long password interval |
| AD-ACC-016 | Identity collisions: UPN and sAMAccountName, invisible Unicode in SPNs, dNSHostName mismatch |
| AD-ACL-009 | Control over dMSAs and their superseded accounts from outside tier 0 |
| AD-INF-018 | Old domain controller machine passwords |
| AD-INF-019 | SYSVOL still replicated by FRS |
| AD-INF-020 | Site and subnet problems |
| AD-INF-021 | Conflict objects, shared SIDs and names |
| AD-KRB-007 | Age of the Entra Kerberos key |
| AD-POL-003 | Content of fine-grained password policies |
Also changed
- JUnit output reports checks that were not judged as skipped, and SARIF carries the notes that say why.
- Older snapshots still load; the new checks and RBCD edges report as not evaluated.
How far this is tested: the positive cases of the new checks and the edges that reach tier 0 are covered by unit tests. The lab domain does not contain them, so they have not fired on a live domain. Reports from real domains are welcome.
Install:
uv tool install ad-doctorFull list of changes: CHANGELOG.md
ad-doctor 0.2.1
Fixes from a review of the what-if work in 0.2.0. Upgrade if you use [expect] closes_all in CI or view --whatif.
[expect] closes_all is now judged on the graph after the changes. In 0.2.0 it could pass when a change took a user out of tier 0 who still had a path, and fail when a route ended at that user.
What-if screen in the viewer
- A result that failed
--checknow says so, with its problems and each change's plan status. In 0.2.0 it looked like a healthy result. - Rerouted paths no longer show as blank rows.
- With
--fixes, each change's label and fix lines are built from the snapshot for its ID and never read from the JSON. An ID the snapshot does not have is refused. - A malformed
--whatiffile is refused with a message instead of a traceback or a blank screen. - The screen states the plan's goal, says when findings were not rechecked, marks absent changes and shows the blast radius of
--account. The About screen lists the embedded plan. Long names no longer widen the page on a phone.
plan
--from NAMEcounts the routes through the account's implicit groups, such as Domain Users.--keepand--allow KINDno longer write acloses_allthat their own plan fails.
Install:
uv tool install ad-doctorFull list of changes: CHANGELOG.md
ad-doctor 0.2.0
What-if: see which changes close the control paths to tier 0 before touching AD.
ad-doctor plancomputes the cheapest set of removals that closes every control path ad-doctor sees (a minimum cut), says "proven minimum" only when it can prove it, lists the sources no removal can close, and orders the changes in steps.--max-changesgives the best it can do within a budget (heuristic, labelled as such).ad-doctor whatifsimulates removals named by change ID, finding fingerprint, path step, chokepoint or spec: paths closed, rerouted or still open, exposure before and after, blast radius, and the findings that would clear or appear, rechecked on the changed snapshot.- Plan file (TOML) tracks each change as done, open, overdue, stale or ineffective against a newer snapshot.
whatif --plan plan.toml --checkexits 2 when its[expect]block fails and 3 when the plan has gone stale, for CI. - Fix lines as PowerShell with
-WhatIfon every writing line, plus a rollback line per change. - Viewer:
view --whatif plan.jsonadds a What-if screen with the steps, change IDs, fix lines and before/after numbers. - Snapshots keep the INHERITED_ACE flag. 0.1.5 still reads a snapshot with no inherited ACE.
Nothing is applied to AD: every result is a simulation on the snapshot. On the lab, the plan takes exposed users from 25 of 26 to 1 of 26 with 15 changes (proven minimum). analyze and paths output is byte for byte that of 0.1.5.
Install:
uv tool install ad-doctorDocs: https://brkozer.github.io/ad-doctor/whatif/
Full list of changes: CHANGELOG.md
ad-doctor 0.1.5
Changed
- Less memory. Four new
Accountfields in 0.1.0 took it to 30 attributes, where
CPython stops sharing dict keys, so every account got a full dict of its own: 1.3 KB more
each on Python 3.12.Account,DirObjectandAcenow use slots.viewlets go of
the parsed file once the snapshot is built, loading shares equal ACEs and member DNs, the
control graph shares node strings,to_jsonno longer makes a deep copy first, OpenGraph
edges share repeated dicts, andexport-graphwrites the file in pieces. On the
250,000-user bench domain on Python 3.12,viewpeaks at 1.43 GB instead of 2.35 GB,
scripts/bench.pyat 2.1 GB instead of about 4.9 GB, andexport-graphat 1.8 GB where
it did not fit in 5 GB before. Findings, fingerprints and every report and export are byte
for byte the same. redactno longer slows down with the square of the domain. Each word is looked up
instead of one pattern of every name running over every string: 1.4 s instead of 40 s at
5,000 users, 4 s at 20,000, and 49 s with a 3.0 GB peak at 250,000 users, where it could
not finish before.- GPO reach. A disabled side stays in with a caveat, because the writer can flip it. A
filter stops a writer of values, but an owner or DACL writer gets a path through a node
GPO ... with its filter changed. User-side reach needs some computer able to read the
GPO, as Windows has required since MS16-072. A filter on a group the snapshot does not
hold, such as another domain's, is assumed to apply, with a caveat and a note. - AD-ACC-013. Any key on a DC or RODC is high, and so is a second key on a computer. CA
servers and Entra Connect sync accounts and servers are read too. A short hash now tells
keys and mappings apart, so the object text and identities of this check differ from 0.1.2. - AD-DEL-005. Graded by the delegating account rather than the host: medium, or high with
protocol transition.sPNMappingschanges only the service class, so ports and instance
names are kept. Entries for another domain get their own wording. - AD-ADCS-019. A URL the directory lists that refuses or answers with anything but 401
gets a note that EPA and NTLM were not judged.
Fixed
- Redaction. A comma in a name defeated it:
Doe\, Johnstayed readable in every DN, and
so did any name with a dotted capital I. Also covered now: enrollment URLs that exist only
in a note, user names and passwords in such URLs, service classes outside a default
sPNMappings, delegation targets with no/, and text around the tags of a malformed
altSecurityIdentitiesvalue. An IP address in an enrollment URL no longer makes the output
impossible to load, a host label such asgroupno longer changes findings, and the Entra
Kerberos trust stays skipped after redaction. --no-probesalso skips the LDAP signing bind, as the docs said. AD-INF-005 then says in
a note that it did not judge. The SYSVOL read has its own--no-sysvol; the docs no longer
put it under--no-probes.- A control path now says in its text what an edge assumes, such as a WMI filter or a
disabled side, instead of only in the OpenGraph export. Fingerprints are unchanged. - A host with a space in
msPKI-Enrollment-Serversno longer ends the scan with a traceback. - Text reports show control characters in directory names as escapes, and Markdown reports
escape<outside code spans.
ad-doctor 0.1.4
Added
- Trust hygiene: AD-INF-015, AD-INF-016, AD-INF-017.
msDS-SupportedEncryptionTypesand
whenChangedare now read on trusts. AD-INF-015 reports downlevel (NT4 style) trusts and
inbound trusts without AES, at low when AES is merely not set. AD-INF-016 reports trusts
unchanged for over 40 days, the limit of PingCastle's T-Inactive, at low for realm trusts.
AD-INF-017 reports outgoing forest trusts without Selective Authentication (DISA V-243485).
The Entra Kerberos trust is skipped. AD-INF-006 keeps SID filtering and TGT delegation, so
nothing is reported twice. An older snapshot says in a note what was not evaluated. - AD-DEL-005: constrained delegation to SPNs no account holds. A target counts as held
when an account has the exact SPN, or the SPN its class maps to throughsPNMappings, which
is now collected. Otherwise the host decides: no computer object is medium, or high with
protocol transition; a host that exists is low; a host outside the scanned domain is low
with review wording. No DNS query is made.redactnow also covers the hosts of SPNs and
delegation targets, which need not belong to a computer in the snapshot.
ad-doctor 0.1.3
Added
- AD-ADCS-019: HTTPS enrollment endpoints where EPA is not verified. The enrollment probe
now also asks/certsrv/over HTTPS and every URL inmsPKI-Enrollment-Servers(CES) for
their authentication methods, with one unauthenticatedGETeach. An endpoint that offers
NTLM or Negotiate over HTTPS is listed with EPA as unknown, at low: IIS shows EPA only to a
client that authenticates, and this probe sends no credentials, so a listed endpoint may or
may not be relayable and an unlisted one is not proven safe. A CES URL over plain HTTP
counts for AD-ADCS-010 and AD-CHAIN-003. New option--https-port(443, 0 skips it). An
older snapshot says in a note that this was not evaluated.