Passive and active subdomain enumeration for any target domain — from the command line or as a Python library.
subdomainenum discovers subdomains through passive tools (subfinder, findomain, assetfinder — which also queries crt.sh and other CT logs internally), optionally brute-forces DNS with gobuster, fuzzes virtual hosts via ffuf, resolves each result, and prints a colour-coded summary.
$ subdomainenum check example.com
Part of the NC3-TestingPlatform.
- Features
- Requirements
- Installation
- External Tools
- CLI Usage
- Python API
- Docker
- Project Structure
- Running Tests
- Contributing
| Source / Mode | Type | What it does |
|---|---|---|
| subfinder | Passive | Runs subfinder -d domain -silent -all (queries all sources) |
| findomain | Passive | Runs findomain --target domain --quiet |
| assetfinder | Passive | Runs assetfinder --subs-only domain |
| dnsrecon | Passive | Runs std,srv with Bing/Yandex/crt.sh (-b -y -k), SPF reverse (-s), AXFR zone transfer (-a), and DNSSEC zone walk (-z). AXFR and zone walk target the domain's authoritative nameservers (public DNS infrastructure), not the target application — so they are classified as passive. Adds --shodan --shodan-active when SHODAN_API_KEY is in the environment. |
| gobuster dns | Active | Brute-forces DNS with a wordlist (gobuster dns --domain domain -w wordlist) |
| ffuf | Active | Fuzzes virtual hosts via the Host header. Target IPs are derived automatically — --url is optional (see Vhost fuzzing). |
| DNS resolution | — | All discovered FQDNs are resolved (A + AAAA in parallel per FQDN) — A/AAAA queries fan out on a shared 256-worker pool, final batch resolves in up to 100 parallel workers. A StreamingResolver overlaps DNS with enumeration: each tool pushes FQDNs into the resolver as soon as it parses them, so by the time enumeration finishes most lookups are already complete. |
Passive and active sources can be run independently or combined (--mode all).
In --mode all, the passive pool (4 workers) and the active pool
(1 worker: gobuster) run concurrently. ffuf then fans out one
worker per resolved target IP (capped at 8). IPs looked up while building
ffuf URLs are cached so no FQDN is DNS-resolved twice.
- Python ≥ 3.11
dnspython≥ 2.6rich≥ 13.7typer≥ 0.12psycopg2-binary≥ 2.9cryptography≥ 42
External tools are optional — absent tools are silently skipped. Run
subdomainenum info to see which are available.
From source (recommended):
git clone https://github.com/NC3-TestingPlatform/subdomainenum.git
cd subdomainenum
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]" # installs the CLI + all dev/test dependenciesThe subdomainenum command is then available in your shell.
Run subdomainenum info to check which tools are detected on your $PATH:
| Tool | Install |
|---|---|
| subfinder | go install github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest |
| findomain | Download from https://github.com/Findomain/Findomain/releases |
| assetfinder | go install github.com/tomnomnom/assetfinder@latest |
| dnsrecon | pip install git+https://github.com/darkoperator/dnsrecon.git@master (installed from source in the Docker image) |
| gobuster | go install github.com/OJ/gobuster/v3@latest |
| ffuf | go install github.com/ffuf/ffuf/v2@latest |
# Queries subfinder, findomain, assetfinder, dnsrecon passive (crt.sh via -k)
subdomainenum check example.com# Brute-force DNS with a wordlist
subdomainenum check example.com \
--mode active \
--wordlist /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
# Brute-force + vhost fuzzing — ffuf runs automatically on all resolved IPs
subdomainenum check example.com \
--mode active \
--wordlist /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt \
--wordlist /opt/SecLists/Discovery/Web-Content/raft-small-words.txt
# Pin ffuf to a specific IP (e.g. a load-balancer VIP or internal host)
subdomainenum check example.com \
--mode active \
--wordlist /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt \
--url http://10.0.0.1# Passive + active, vhost fuzzing auto-targets all discovered IPs
subdomainenum check example.com \
--mode all \
--wordlist /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
# Override the ffuf target with a specific URL
subdomainenum check example.com \
--mode all \
--wordlist /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt \
--url http://10.0.0.1ffuf is launched automatically whenever a wordlist is supplied — no --url flag required.
How target IPs are chosen:
- The base domain (
example.com) is resolved (A + AAAA). - Every subdomain discovered in the passive phase is resolved in parallel via the shared
StreamingResolver— results already cached from enumeration are reused at zero extra DNS cost. - All unique IPs are collected and deduplicated. IPv6 addresses are bracketed (
[::1]). - ffuf is launched once per IP, up to 8 workers in parallel, fuzzing the
Hostheader againsthttp://<ip>.
If --url is given, that single URL is used instead and no automatic resolution happens.
When does ffuf actually run?
| Mode | ffuf runs? | Target source |
|---|---|---|
passive |
No | — |
active |
Yes (if wordlist provided) | Base domain IPs + active-enum subdomain IPs |
all |
Yes (if wordlist provided) | Base domain IPs + passive subdomain IPs |
Results are deduplicated: the same virtual host found across multiple IPs appears once.
# Machine-readable output (stdout)
subdomainenum check example.com --json
# Save to file
subdomainenum check example.com --json --output report.json# Save each tool's raw output to an auto-named log file
subdomainenum check example.com --debug-log
# Also works with --json
subdomainenum check example.com --json --debug-logWhen --debug-log is specified, every line emitted by each tool is written to
a file named <domain>_YYYYMMDD_HHMMSS.log. The file is placed in /reports/
when that volume is mounted (Docker), otherwise in the current directory. No
debug output is sent to stderr — a brief Debug log → <path> confirmation
appears at the end.
# Adjust per-query DNS resolution timeout (default 5.0 s)
subdomainenum check example.com --timeout 10subdomainenum infosubdomainenum --versionfrom subdomainenum.assessor import assess
from subdomainenum.models import EnumMode
from subdomainenum.reporter import print_report
report = assess(
"example.com",
mode=EnumMode.PASSIVE, # passive | active | all
wordlist=None, # required for active/all
url=None, # optional: pin ffuf to one URL; omit to auto-target all resolved IPs
timeout=5.0, # DNS resolution timeout per query
progress_cb=print, # optional: called with status strings
)
print_report(report)from subdomainenum.assessor import assess
report = assess("example.com")
print(report.domain) # "example.com"
print(report.mode.value) # "passive"
# Subdomains
for sub in report.subdomains:
print(sub.fqdn, sub.status.value, sub.ip_addresses, sub.tools)
# Virtual hosts (ffuf, only in active/all mode with --url)
for vhost in report.vhosts:
print(vhost.vhost, vhost.status_code, vhost.content_length)
# Per-tool results
for tool in report.tools:
print(tool.name, len(tool.subdomains), tool.available, tool.error)Status values: ALIVE, DEAD, TIMEOUT, ERROR, FOUND, NOT_FOUND, SKIPPED.
import json
from subdomainenum.assessor import assess
from subdomainenum.reporter import to_dict
report = assess("example.com")
print(json.dumps(to_dict(report), indent=2))Top-level schema:
| Key | Type | Notes |
|---|---|---|
domain |
string | Target base domain |
mode |
string | "passive", "active", or "all" |
subdomains[] |
array | {fqdn, status, alive, ip_addresses, tools} |
vhosts[] |
array | {vhost, status_code, content_length} |
tools[] |
array | {name, count, available, error, timed_out, mode} |
Each tools[] entry reflects the lifecycle of one tool run: available=false means the binary or API was missing; timed_out=true means the tool was killed by the wall-clock or idle-timeout watchdog (distinct from error); error is a string when the tool raised or reported an error, otherwise null.
A Docker image with all tools pre-installed and SecLists (DNS + Web-Content
directories) bundled is available via the included Dockerfile.
# Build the image
docker compose build
# Passive check
docker compose run subdomainenum check example.com
# Active check with bundled SecLists wordlist
docker compose run subdomainenum check example.com \
--mode active \
--wordlist /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt
# All modes + vhost fuzzing, save report to host ./reports/
docker compose run subdomainenum check example.com \
--mode all \
--wordlist /opt/SecLists/Discovery/DNS/subdomains-top1million-5000.txt \
--url http://10.0.0.1 \
--json \
--output /reports/example.json
# Check available tools inside the container
docker compose run subdomainenum infosubdomainenum/
├── subdomainenum/
│ ├── __init__.py Package version
│ ├── models.py Dataclasses: SubdomainResult, VhostResult,
│ │ ToolResult, EnumReport + Status/EnumMode enums
│ ├── dns_utils.py resolve_ips(), is_alive() — dnspython wrappers
│ │ (A+AAAA queried in parallel on a shared pool)
│ ├── streaming.py StreamingResolver — background DNS queue that
│ │ overlaps resolution with enumeration
│ ├── constants.py ACTIVE_TOOLS registry, detect_tools(), get_install_hint()
│ ├── assessor.py assess() — orchestrates passive + active sources
│ ├── reporter.py Rich terminal output + to_dict() + save_report()
│ ├── verdict.py build_verdict() — factual count summary
│ ├── cli.py Typer CLI: check, info sub-commands
│ └── tools/
│ ├── tool_runner.py subprocess wrapper used by all active tools
│ ├── subfinder.py subfinder wrapper
│ ├── findomain.py findomain wrapper
│ ├── assetfinder.py assetfinder wrapper
│ ├── dnsrecon.py dnsrecon wrapper (std,srv + Bing/Yandex/crt.sh/SPF
│ │ + AXFR + DNSSEC zone walk; always passive)
│ ├── gobuster_dns.py gobuster dns wrapper (sole DNS brute-forcer)
│ └── ffuf.py ffuf vhost fuzzing wrapper
├── tests/
│ ├── __init__.py
│ ├── test_models.py
│ ├── test_verdict.py
│ ├── test_constants.py
│ ├── test_dns_utils.py
│ ├── test_assessor.py
│ ├── test_reporter.py
│ ├── test_cli.py
│ └── tools/
│ ├── test_tool_runner.py
│ └── test_wrappers.py
├── Dockerfile
├── docker-compose.yml
├── pyproject.toml
├── requirements.txt
├── requirements-dev.txt
└── README.md
source .venv/bin/activate
# Run all tests with coverage (configured automatically via pyproject.toml)
pytest
# Quick run (short tracebacks)
pytest --tb=short -q
# Run a single module
pytest tests/test_assessor.py -v
# Run a single test class
pytest tests/test_cli.py::TestCheckCommand -vThe test suite has 338 tests and achieves 99% coverage across all modules.
All DNS I/O (dns.resolver.Resolver.resolve), TLS
sockets, and subprocess calls are mocked at the boundary — no test touches a real
server or the internet.
- Fork the repository and create a feature branch.
- Add or update tests — the project targets 80%+ unit test coverage.
- Run
pytestand confirm all tests pass before opening a pull request. - Follow the existing docstring format (reStructuredText / docutils field lists).
- Use conventional commits:
fix:,feat:,refactor:,test:,docs:,chore:
GPLv3 — see LICENSE for details.