| Layer | Technology | Version |
|---|---|---|
| Language | Python | ≥ 3.11 |
| CLI framework | Typer | ≥ 0.12 |
| Terminal output | Rich | ≥ 13.7 |
| DNS resolver | dnspython | ≥ 2.6 |
| Testing | pytest + pytest-cov + pytest-mock | ≥ 8 / ≥ 5 / ≥ 3.12 |
pip install -e ".[dev]" # install in editable mode with dev deps
subdomainenum check example.com # passive enumeration (default)
subdomainenum check example.com --mode active --wordlist /opt/seclists/Discovery/DNS/subdomains-top1million-5000.txt
subdomainenum check example.com --mode all --url http://10.0.0.1 --json
subdomainenum info # show tool availability
python -m pytest # run tests with coverage
python -m pytest --tb=short -q # quick runsubdomainenum/
cli.py → Typer entry point; calls assessor + reporter; I/O, validation
assessor.py → Public API: assess(...) → EnumReport (ThreadPoolExecutor orchestration)
models.py → Status, EnumMode enums; SubdomainResult, EnumReport, VhostResult dataclasses
constants.py → ACTIVE_TOOLS registry; detect_tools(); get_install_hint()
dns_utils.py → resolve_ips(), is_alive() via dnspython (never raises)
reporter.py → Rich terminal renderers; to_dict(); save_report()
verdict.py → VerdictSummary dataclass + make_verdict() (pure, no I/O)
tools/
tool_runner.py → run_tool(): subprocess wrapper with timeout + streaming
subfinder.py → run_subfinder()
findomain.py → run_findomain()
assetfinder.py → run_assetfinder()
dnsrecon.py → run_dnsrecon()
gobuster_dns.py → run_gobuster_dns()
ffuf.py → run_ffuf() → list[VhostResult]
tests/
conftest.py → shared fixtures
test_*.py → pytest, AAA pattern, class-per-feature grouping
tools/
test_tool_runner.py
test_wrappers.py
cli.pyvalidates domain and flags, buildsdebug_cb/progress_cbcli.pycallsassess(domain, mode, ...)fromassessor.pyassessor.pyfans out passive tools in aThreadPoolExecutor(4 parallel workers)- The non-ffuf active tools run in their own
ThreadPoolExecutorvia_run_active_enum. The pool is always gobuster (1 worker), regardless of mode. dnsrecon is never in the active pool; AXFR and DNSSEC zone walk are performed passively in all modes. InALLmode the passive pool and the active-enum pool run concurrently under an outer executor (phase fusion). ffufruns after the enumeration pools drain so it can target IPs resolved from passive FQDNs; multiple URLs are fuzzed in parallel (_run_ffuf_fanout, capped at 8 workers).- A
StreamingResolver(subdomainenum/streaming.py) runs alongside enumeration: each tool wrapper accepts anfqdn_cb;assess()wires it toStreamingResolver.submit, so FQDNs are resolved in the background as soon as they are parsed. Per-FQDNAandAAAAqueries fan out on a shared 256-worker pool indns_utils.py(so the slower of the two queries bounds per-FQDN latency). The final_resolve_allcall then uses up to 100 workers to fetch anything the streaming resolver didn't already complete; passive-phase IPs are cached and reused for ffuf URL enrichment to avoid duplicate lookups. EnumReportis returned;reporter.pyrenders with Rich or serialises to JSON
| Boundary | Module | What to patch |
|---|---|---|
| Subprocess tools | tools/tool_runner.py |
subprocess.Popen |
| DNS resolution | dns_utils.py |
dns.resolver.Resolver.resolve |
passive— subfinder, findomain, assetfinder, dnsrecon (std,srvwith Bing/Yandex/crt.sh/SPF/AXFR/DNSSEC zone walk; assetfinder also queries crt.sh/certspotter internally). AXFR and DNSSEC zone walk target public authoritative nameservers, not the target application, so they belong in the passive phase.active— gobuster dns (brute-force, requires--wordlist); ffuf runs only when--urlor resolved base-domain IPs provide targets. dnsrecon is never in the active pool.all— both phases: passive runs the 4 passive sources (including dnsrecon), active runs gobuster only.
- Mock at the I/O boundary listed in the table above — never mock
assess()itself - Use
monkeypatch(pytest-mock) orunittest.mock.patch - Test class naming:
TestRunTool,TestQueryCrtSh,TestAssess, etc. (class-per-feature) - AAA pattern: Arrange → Act → Assert in every test method
- Coverage target: ≥ 80% (configured in
pyproject.toml) - Current test count: 339 tests
- Add a
query_<name>(domain) → ToolResultfunction directly inassessor.pyor a new helper module - Import and add it to the passive sources list in
assessor.py - Wire
debug_cbif the source is streaming - Write tests in
tests/test_assessor.pyor a dedicated file
- Create
subdomainenum/tools/<name>.pyusingrun_tool()fromtool_runner.py - Add an entry to
ACTIVE_TOOLSinconstants.py(binary name + install hint) - Import and add it to the active sources in
assessor.py - Write tests in
tests/tools/test_wrappers.py(or a new file)
--debug-log (boolean flag, no argument) collects each tool's raw output to an
auto-named log file: <domain>_YYYYMMDD_HHMMSS.log. When /reports/ is a
mounted directory (Docker), the file is written there so it survives
docker compose run --rm; otherwise it lands in the current directory.
DebugLogger in debug_logger.py is the thread-safe collector; it receives
debug_cb, cmd_cb, and finish_cb callbacks from assess() and writes one
section per source (command, all output lines, status, optional error).
No debug output is sent to stderr. After the scan a brief Debug log → <path>
confirmation is printed to stderr.
--json→to_dict(report)printed as JSON to stdout (machine-readable)--output <path>→ saves rendered report; extension determines format:.txtplain text,.svgSVG image,.htmlself-contained HTML (via Rich record)
- Both flags can be combined
sudo docker compose up -d --build # builds all Go tools in stage 1; installs package in stage 2
# Reports volume is mounted at ./reports → /reports inside containerEnvironment variables for wordlist paths: DEFAULT_DNS_WORDLIST, DEFAULT_VHOST_WORDLIST
from __future__ import annotationsat the top of every module- Snake_case for all files, functions, and variables
- Sphinx-style docstrings:
:param name:,:returns:,:rtype:(no:type:— type annotations on signatures are sufficient) - Conventional commits:
fix:,feat:,fix(scope):,refactor:,test:,docs: - All external calls (subprocess, HTTP, TLS, DNS) are wrapped to never raise — errors captured in
ToolResult.error - No CI config currently present
Run these checks and update these files as needed — do not skip any step:
# 1. Verify tests pass and coverage is ≥ 80%
pytest
# 2. Check for lint issues
ruff check subdomainenum/If the test count changed, update both occurrences in README.md:
- Badge line (near top):
 - Running Tests section: "The test suite has NNN tests…" sentence
Also update the count in this file (CLAUDE.md) under "Testing Conventions".
Before pushing, update CHANGELOG.md: add your changes under ## [Unreleased]
using the standard sections (### Added, ### Changed, ### Fixed, ### Removed).
When bumping the version, move unreleased items to a new ## [x.y.z] — YYYY-MM-DD
section and update the comparison links at the bottom of CHANGELOG.md.
The .tours/ directory contains CodeTour walkthroughs (VS Code / JetBrains extension). Tours are checked into the repo and should stay accurate.
When to update a tour:
- Adding or removing a passive or active source
- Changing the public
assess()signature or its phase structure - Reorganising the
tools/directory - Changing the request lifecycle (passive → active → DNS order)
- Adding a major new subsystem (new output format, new debug mechanism, etc.)
When you do NOT need to update a tour:
- Bug fixes or internal refactors that don't move key anchors
- Line-number drift of a few lines (tours reference landmarks, not exact lines)
- New tests or documentation that don't affect the runtime call graph
Current tours:
.tours/new-joiner-architecture.tour— end-to-end request lifecycle for new contributors
When committing a set of changes, bump the version using semver:
- patch (
0.1.x) — bug fixes, refactor, docs, lint - minor (
0.x.0) — new sources, new CLI options, new features - major (
x.0.0) — breaking API changes
Two files must always be updated together:
pyproject.toml→version = "x.y.z"subdomainenum/__init__.py→ fallback__version__ = "x.y.z"(theexceptbranch)
Every version bump must be followed by a GitHub release. Do not leave a version tag without a release.
After bumping the version, committing, and pushing:
# Tag the version commit and push
git tag vX.Y.Z
git push origin vX.Y.Z
# Create the GitHub release
gh release create vX.Y.Z \
--title "vX.Y.Z" \
--notes "$(cat <<'EOF'
## What's changed
<Copy the ### Added / ### Changed / ### Fixed / ### Removed blocks verbatim
from the [X.Y.Z] section in CHANGELOG.md>
## Impact
<1–3 sentences: what this means for users — what improves, what breaks,
whether the upgrade is urgent (e.g. new enumeration source, tool wrapper
fix, DNS resolution change, Docker image update, etc.)>
## Migration
<Only for minor/major bumps: list any CLI flags, `assess()` parameters,
`ACTIVE_TOOLS` registry changes, or Docker environment variable renames
that require user action. Omit for patch releases.>
---
**Full changelog:** https://github.com/NC3-TestingPlatform/subdomainenum/blob/master/CHANGELOG.md
EOF
)"Release body checklist:
- Changelog entries for this version copied verbatim
- Impact note written (even one sentence is enough)
- Migration note present if CLI flags,
assess()signature, or Docker env vars changed - Full changelog link at the bottom
Conventions:
- Tag and title:
vX.Y.Z— semver,v-prefixed, must matchpyproject.tomlversion - Do not mark as draft or pre-release for normal semver releases