# mhunt

Find and inventory what's exposed — on your network and across a domain.

`mhunt` discovers and inventories your attack surface. Point it at a network or a
domain and it tells you what's there and what's at risk:

- **inventory a local network** and the devices on it (printers, routers, NAS, cameras)
- **map a domain's external attack surface** (subdomains, cloud assets, exposed services)
- **find exposures and known vulnerabilities**
- **assess a website's security posture** (TLS, HTTP security headers, web technologies)
- **monitor change over time** (`--diff`)
- **report findings to Mondoo Platform**, or run local-only

New here? Just run `mhunt` (or `mhunt start`) for a guided setup that picks the
right scan for your goal.

By default, findings are uploaded to Mondoo Platform using a service account
(from `--config` or `MONDOO_CONFIG_PATH`). If no credentials are available, the
tool runs locally. Use `--incognito` to explicitly skip upload even when
credentials are available.

## Install

```sh
go install go.mondoo.com/mhunt/cmd/mhunt@latest
```

Or build from source:

```sh
make build      # produces ./mhunt
make install    # installs into $GOBIN
```

### Verifying a release

Each GitHub release ships a `checksums.txt` (SHA-256 of every archive, package,
**and** the SBOM) signed with [cosign](https://github.com/sigstore/cosign) keyless
(Sigstore), a CycloneDX **SBOM** (`mhunt_<version>.cyclonedx.sbom.json`), and
per-artifact [SLSA build provenance](https://slsa.dev). To verify (adjust the
version):

```sh
# 1. Checksums signature — the cosign cert's identity is mhunt's release
#    workflow, so a valid signature proves CI produced this checksums.txt:
cosign verify-blob \
  --certificate checksums.txt.pem --signature checksums.txt.sig \
  --certificate-identity-regexp '^https://github.com/mondoohq/mhunt' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  checksums.txt

# 2. Confirm your artifact (and the SBOM) match the signed checksums:
sha256sum -c checksums.txt --ignore-missing

# 3. Per-artifact build provenance (authenticates an individual binary, which the
#    checksum signature only covers transitively):
gh attestation verify mhunt_<version>_linux_amd64.tar.gz --repo mondoohq/mhunt
```

## Scanning

`mhunt scan <domain>` runs one or many recon modules against a single domain and
reports the combined result. Each module is also available as a **flat shortcut**
(`mhunt aws <domain>`, `mhunt exposure <domain>`, …). You always pass a bare
domain — the scheme/URL handling per module is automatic.

```sh
mhunt scan example.com                      # passive modules (default)
mhunt scan example.com -m aws,exposure  # a chosen set
mhunt scan example.com -m all               # every module
mhunt scan a.com b.com -m all               # several targets
mhunt scan example.com --format json -o out.json

mhunt aws example.com                       # single-module shortcut
mhunt exposure --api-key KEY example.com
```

With no `--module`, the passive/public modules run (`aws`, `azure`, `gcp`,
`webtech`, `takeover`). `exposure` (needs an API key), `services`, and `vuln`
(active scans that need an external scanner binary) are opt-in via `-m`. A shortcut
and `scan -m <module>` share the exact same flags.

### Local network & device discovery

Point `mhunt` at a host or IP range instead of a domain to inventory a local
network — hosts, services, and network **devices** (printers, routers, NAS, cameras)
identified down to vendor / model / firmware for CVE matching:

```sh
mhunt scan 192.168.0.0/24 --scan-config discovery -m services,mdns,ssdp,wsd
```

See [docs/network-discovery.md](docs/network-discovery.md) for the modules, device
identification, safety (printer-port handling), performance tuning, and reporting.

### Job config

A whole run — targets, which modules are enabled, their options, output, and
upload settings — can be saved as a YAML job config and replayed. Configure a
run with flags once and capture it with `--write-job`; replay it with `--job`
(the file is the source of truth — other flags are ignored):

```sh
# configure with flags, save the effective config, and run:
mhunt scan example.com example.org -m aws,vuln \
  --vuln-severity high,critical --write-job job.yaml

# replay it later (multi-target JSON is one document keyed by target):
mhunt scan --job job.yaml
```

```yaml
# job.yaml
targets: [example.com, example.org]
modules:
  aws:          { enabled: true }
  azure:        { enabled: true }
  gcp:          { enabled: true }
  webtech:      { enabled: true, max_phase: 3 }
  takeover:     { enabled: true }
  exposure: { enabled: false, api_key: "" }
  services:     { enabled: false, top_ports: 1000 }
  vuln:         { enabled: true, severity: "high,critical", rate_limit: 1000, timeout: "30m" }
concurrency: 50
timeout: "5s"
output: { format: json, file: results.json }
incognito: false
no_download: false
```

### Continuous monitoring

One-shot recon shows the surface *now*; the EASM value is what *changed* — "3 new
hosts, 1 newly-exposed port since yesterday". `scan --diff` compares a run against
the last saved snapshot for each target and reports new/removed assets and
findings (or "baseline created" on the first run), then saves a new snapshot:

```sh
# first run establishes the baseline; later runs show the delta:
mhunt scan example.com --diff
mhunt scan example.com --diff      # → "+2 asset(s), +1 finding(s) since …"

# save a snapshot without diffing:
mhunt scan example.com --save-snapshot

# diff the two latest snapshots without re-scanning (--json for machine output):
mhunt diff example.com
```

Snapshots are normalized, sorted JSON (the discovered assets + findings, not every
module field) under `~/.mhunt/snapshots/<target>/<timestamp>.json` (override the
base with `$MHUNT_HOME`). A normal `scan` with neither flag writes nothing and
behaves exactly as before. The diff prints to stderr, so a `--format json` / `-o`
result on stdout stays clean. See [ADR 0016](docs/adr/0016-continuous-monitoring-diff.md).

### Graphing the attack surface

For a fast overview of the identified infrastructure and its vulnerable
domains/IPs, render the discovered assets + findings as a graph: the target at
the center, subdomains/hosts/IPs/services/buckets branching off it, open ports on
their hosts, and any vulnerable nodes highlighted by severity. The graph is built
from the same normalized snapshot the diff uses, so every module contributes for
free. See [ADR 0034](docs/adr/0034-infrastructure-graph.md).

The CLI emits the graph as **data** — Graphviz DOT (default) or the JSON
node/edge model — and leaves rendering an image to external tooling:

```sh
# graph a target's latest snapshot (read-only; no re-scan), pipe to Graphviz:
mhunt graph example.com | dot -Tsvg -o infra.svg

# the node/edge model for a custom viewer (cytoscape/d3/…):
mhunt graph example.com --format json -o infra.json

# or produce the graph from a fresh run (one scan, both outputs):
mhunt scan example.com -m all --graph infra.dot
mhunt scan example.com -m all --graph infra.json --graph-format json
```

The graph is the only view that carries **edges** and **per-node severity**; the
scan JSON is raw per-module data and the snapshot is a flat asset/finding
inventory. Beyond containment edges (target→subdomain, host→port), it draws
**pivot edges** between assets that share an EASM signal — the same favicon hash,
ASN, or TLS issuer — clustering likely-related infrastructure the way the
platform's attribution graph does ([ADR 0032](docs/adr/0032-easm-emission-contract.md)).

### Deploying on a VM (on-prem network scanning)

mhunt can run as a scheduled job on a VM inside a network, sweeping CIDR ranges
with `nmap`/`naabu` and uploading both findings and the discovered hosts (as
inventory assets) to Mondoo. See **[docs/deployment/on-prem.md](docs/deployment/on-prem.md)**
for the full setup — an example job config, systemd timer / cron units, and the
privilege (`setcap`) and logging guidance.

```sh
mhunt scan --job /etc/mhunt/network-scan.yaml
```

### External tool binaries

mhunt shells out to `nuclei` and `naabu` (and `nmap` for the `services` module).
The release packages are **self-contained**, so in most cases you don't install
anything:

- **deb / rpm** bundle `nuclei` and `naabu` (in `/usr/libexec/mhunt`) and declare
  `nmap` as a package dependency, so `apt`/`dnf` pull it in automatically.
- **tar.gz / zip** ship `nuclei` and `naabu` next to the `mhunt` binary (no
  `windows/arm64` build exists upstream, so that archive is mhunt-only). `nmap`
  isn't a portable binary mhunt can download, so it must be installed natively:
  `brew install nmap` (macOS), your package manager (Linux), or the
  [nmap.org installer](https://nmap.org/download) (Windows, includes the Npcap
  driver). Alternatively, run the **Docker** image below — it bundles `nmap`.
- **Docker** image bundles all three (see below).

Why `nmap` isn't auto-downloaded like `nuclei`/`naabu`: it ships OS installers and
source only, not a single portable binary, and its Windows installer bundles the
[Npcap](https://npcap.com/) driver, whose license reserves silent/unattended
installation for a paid OEM license. So mhunt neither redistributes nor
auto-installs `nmap`; when the `services` module needs it and none is found, mhunt
errors with an OS-appropriate install hint. Run the self-contained image (which
bundles `nmap`) if you'd rather not install it on the host.

Resolution order at runtime: a binary **bundled in the package** (next to the
executable, or `/usr/libexec/mhunt`) wins, then mhunt's managed cache
(`~/.mhunt/bin`), then `PATH`. If a tool is still missing, `nuclei`/`naabu` are
downloaded (pinned version, SHA-256 verified) into `~/.mhunt/bin`. Use
`--no-download` to disable downloads — bundled binaries still work offline; a
missing required tool then errors with an install hint. Override the cache with
`$MHUNT_HOME`, or point at a bundled-binary directory with `$MHUNT_BUNDLED_BIN`.

### Running in Docker

A self-contained image bundles `mhunt` together with the scanners it shells out
to — `nmap`, `naabu`, `nuclei`, and `cnspec` — so no tool download happens at
scan time.
It is published per release to the GitHub Container Registry for `linux/amd64`
and `linux/arm64`:

```sh
docker pull ghcr.io/mondoohq/mhunt:latest

# Scan a domain. Mount a workdir to keep results, configs, and snapshots.
docker run --rm -v "$PWD:/workspace" \
  ghcr.io/mondoohq/mhunt:latest scan example.com

# Discover + fingerprint an internal network (Linux host). --network host lets
# nmap/naabu see the LAN directly. `--scan-config discovery` uses connect scans,
# so no extra capabilities are needed; for SYN/OS-detection scans, see "Network
# capabilities" below.
docker run --rm --network host -v "$PWD:/workspace" \
  ghcr.io/mondoohq/mhunt:latest scan 10.0.0.0/24 --scan-config discovery
```

For on-prem network scanning (scheduling, privileges, `--network host` vs
macvlan, and the macOS/Windows caveat that Docker Desktop can't reach the
physical LAN), see [docs/deployment/on-prem.md](docs/deployment/on-prem.md).

#### Network capabilities

The container runs as an unprivileged `mhunt` user. SYN scans
(`--nmap-profile privileged-discovery`, `--naabu-syn`) and `nmap` OS detection
(`--nmap-os`) use **raw sockets**, which need the **`NET_RAW`** capability (and,
for some lower-level network operations, **`NET_ADMIN`**). The bundled `nmap` and
`naabu` carry the `cap_net_raw,cap_net_admin` file capability, so they gain these
as the unprivileged user — **but only for capabilities that are in the container's
capability set**. A capability the runtime has dropped can't be raised by a file
capability, so raw-socket scans then fail with a permission error.

- **`docker run`** — `NET_RAW` is in Docker's default capability set, so SYN/connect
  scans and OS detection work out of the box, no `--privileged` needed. For the few
  operations that need `NET_ADMIN`, add it explicitly:

  ```sh
  docker run --rm --network host --cap-add=NET_ADMIN -v "$PWD:/workspace" \
    ghcr.io/mondoohq/mhunt:latest scan example.com
  ```

- **Kubernetes / other orchestrators / hardened runtimes** — the default capability
  set is usually **reduced** (e.g. the restricted Pod Security Standard drops
  `NET_RAW`), so grant them back on the container's `securityContext`:

  ```yaml
  securityContext:
    capabilities:
      add: ["NET_RAW", "NET_ADMIN"]
  ```

Unprivileged TCP **connect** scans (the default when you don't request SYN/OS
detection) need no added capabilities at all — drop `privileged-discovery` and omit
`--naabu-syn` to run with the reduced set.

`/workspace` is owned by the `mhunt` user; if you bind-mount a host directory it
owns, output stays writable. To write as your own host UID instead, add
`--user "$(id -u):$(id -g)"`.

Build the image yourself from the repo root with `make docker` (host arch), or
for the multi-arch manifest:

```sh
docker buildx build --platform linux/amd64,linux/arm64 -t mhunt:latest .
```

#### Podman

The image is a standard OCI image, so Podman runs it as a drop-in replacement for
Docker — swap `docker` for `podman` in the commands above:

```sh
podman run --rm -v "$PWD:/workspace" \
  ghcr.io/mondoohq/mhunt:latest scan example.com
```

Two things differ from Docker and matter for scanning:

- **Capabilities.** Unlike Docker, Podman does **not** carry `NET_RAW` in its
  default capability set, so it behaves like the reduced-set case above: SYN scans
  (`--naabu-syn`, `--nmap-profile privileged-discovery`) and `nmap` OS detection
  (`--nmap-os`) need it granted back explicitly. Plain connect scans (the default)
  need no added capabilities.

  ```sh
  sudo podman run --rm --network host \
    --cap-add=NET_RAW --cap-add=NET_ADMIN -v "$PWD:/workspace" \
    ghcr.io/mondoohq/mhunt:latest scan 10.0.0.0/24 --scan-config discovery
  ```

- **Rootless vs. rootful.** A rootless container's user namespace doesn't own the
  host network stack, so raw-socket scans over `--network host` (local-LAN
  discovery, SYN, OS detection) need **rootful** Podman (`sudo podman`, as above).
  Rootless Podman is fine for external connect/version scanning. Rootless Podman
  also remaps host UIDs, so if you need bind-mounted output owned by your host
  user, add `--userns=keep-id` (optionally with `--user "$(id -u):$(id -g)"`).

### Cloud IP ranges

The `aws`, `azure`, and `gcp` modules match a target's resolved IPs against each
provider's published IP ranges to identify the owning cloud/service. The ranges
are fetched on first use from the authoritative sources
([AWS](https://ip-ranges.amazonaws.com/ip-ranges.json),
[GCP](https://www.gstatic.com/ipranges/cloud.json), and the weekly Azure
[Service Tags](https://www.microsoft.com/en-us/download/details.aspx?id=56519)),
then cached under `~/.mhunt/ipranges` (override with `$MHUNT_HOME`) and refreshed
weekly — so a scan always uses fresh ranges without rebuilding mhunt. A small
embedded subset ships with the binary, so matching still works offline or under
`--no-download`.

### Modules

Module IDs name the capability, not the tool that implements it — the underlying
scanner is an implementation detail (see
[docs/development/modules.md](docs/development/modules.md) for the module → tool
mapping). The previous tool-based names still work as deprecated aliases on `-m`
and in job configs, so existing scripts and saved configs keep running.

| Module         | Default | Description                                              |
| -------------- | :-----: | ------------------------------------------------------- |
| `aws`          |   ✓     | AWS public asset discovery                              |
| `azure`        |   ✓     | Azure / Entra ID public asset discovery                |
| `gcp`          |   ✓     | GCP public asset discovery                              |
| `webtech`      |   ✓     | Web technology fingerprinting                          |
| `takeover`     |   ✓     | Subdomain-takeover check (dangling CNAME)              |
| `httpprobe`    |   ✓     | HTTP probing (status/title/CDN/TLS) + favicon hash     |
| `subdomains`   |   ✓     | Subdomain / asset enumeration                          |
| `asn`          |   ✓     | ASN / owned IP-range discovery (BGP prefixes, reverse DNS) |
| `breach`       |   ✓     | Known data breaches affecting the domain                |
| `whois`        |   ✓     | Domain registration lookup                              |
| `exposure`     |         | Passive internet-exposure intel — host/service & CVE (needs API key) |
| `services`     |         | Service/version detection                              |
| `vuln`         |         | Vulnerability scan                                     |
| `crawl`        |         | Web crawling / endpoint discovery                      |
| `origin-ip`    |         | Real origin IP behind a CDN/Cloudflare — experimental (DNS-only; optional API keys) |
| `webscan`      |         | Dynamic web-app scan (DAST)                            |
| `tls`          |         | TLS/SSL certificate and cipher security (cnspec policy) |
| `httpsec`      |         | HTTP response-header and transport hardening (cnspec policy) |

Every module converts its results to Mondoo Finding Exchange (FEX/VEX) documents
and uploads them automatically, unless `--incognito` is set or no credentials are
available (in which case results are still printed locally). Findings map to
severity by signal:

- **Dangling CNAMEs** → `HIGH` security (subdomain-takeover risk).
- **Publicly listable/readable buckets and Firebase DBs** → `MEDIUM` security when
  ownership is corroborated (name derived from the target, or a matching region),
  downgraded to `LOW` when the global-namespace name only loosely matches the
  target (likely a different org's resource). The `confidence` property records
  the tier.
- **`exposure` CVEs** → VEX with the actual CVSS score; **open ports** → `HIGH`.
- **`services` NSE vulnerability scripts** (`--nmap-profile vuln-detection`, or
  `--nmap-scripts vulners,vuln`) → `vulners` CVEs and CVE-referencing `vuln`-category
  probes become VEX (CVE id + CVSS); a CVE-less `vuln` probe becomes a severity-mapped FEX.
  Weak-TLS scripts (`ssl-enum-ciphers`, `ssl-cert`) → security FEX.
- **`vuln` scan results** → CVE templates become VEX (CVE id + CVSS); other templates
  become severity-mapped FEX (info → informational, else security).
- **Discovered services/subdomains, web technologies, `exposure` open services,
  and the per-provider cloud footprint** (detected managed services/regions/tenant) →
  informational asset/software inventory.

Every module's results map to FEX/VEX, so the findings uploaded to Mondoo match
what the result envelope shows.

## Output convention

Every recon command shares one convention:

- `-o/--output FILE` — write here; default stdout.
- `--format json|text` — default `text`. (`--json` is a hidden alias.)

A scan emits a combined JSON envelope keyed by module:

```json
{
  "domain": "example.com",
  "scanned_at": "2026-06-05T...",
  "modules": {
    "aws":          { "data": { ... } },
    "exposure": { "skipped": "no Shodan API key" }
  }
}
```

## Commands

| Command                   | Description                                                  |
| ------------------------- | ------------------------------------------------------------ |
| `scan <domain>`           | Run one or many recon modules against a domain               |
| `doctor`                  | Diagnose network reachability to Mondoo (preflight, ADR 0037)|
| `diff <target>`           | Diff the two latest snapshots of a target (no re-scan)       |
| `graph <target>`          | Graph a target's infrastructure (JSON/DOT) from its snapshot |
| `aws` / `azure` / `gcp`   | Per-module cloud asset-discovery shortcuts                   |
| `exposure` / `services` / `vuln` | Per-module exposure / service-detection / vuln-scan shortcuts |
| `takeover`                | Per-module subdomain-takeover shortcut (single domain, envelope) |
| `webtech`                 | Per-module web-fingerprint shortcut (domain, envelope)       |
| `dangling-cname <…>`      | Batch dangling-CNAME checker (file input, streaming NDJSON)  |
| `webbom`                  | WebBOM subtree (`build` / `serve` / `stats` / `db` / `scan`) |

The per-module shortcuts are generated from the module registry, so they are all
structurally identical and share the output convention above; each is equivalent
to `mhunt scan -m <module> <domain>`. Each shortcut also answers to its previous,
tool-based name as a deprecated alias (e.g. `mhunt nmap` → `services`,
`mhunt webbom-scan` → `webtech`), so existing scripts keep working.

Two pairs are intentional — a uniform shortcut plus a specialized command:

- `takeover <domain>` (single domain, combined envelope) vs `dangling-cname`
  (batch file/list input, streaming NDJSON via `--json`, `--exit-code`).
- `webtech <domain>` (uniform shortcut) vs `webbom scan <url>` (the subtree's
  URL-based scanner, with `--remote`/`--pretty` and the `build`/`serve`/`db`
  operations). Note the flat shortcut renamed from `webbom-scan` to `webtech`;
  the separate `webbom` subtree keeps its name, and `webbom-scan` still works as
  an alias of `webtech`.

Run `mhunt <command> --help` for details, or `mhunt --version` for build info.

## Global flags

| Flag           | Description                                                       |
| -------------- | ---------------------------------------------------------------- |
| `--incognito`           | Skip upload to Mondoo Platform (local scan only)                |
| `--config`              | Path to Mondoo service account config (or `MONDOO_CONFIG_PATH`) |
| `--scope-mrn`           | Mondoo scope MRN — space or org (overrides config)             |
| `--no-download`         | Don't auto-download missing tool binaries; error if absent      |

## Development

Run `make help` to see all targets. Common ones:

```sh
make build       # build ./mhunt for the current platform (with version ldflags)
make test        # run all tests
make integration # live scans of scanme.nmap.org + contoso.com (needs network + nmap)
make lint        # run golangci-lint
make fmt         # gofmt (skips generated .pb.go)
make vet         # go vet
make tidy        # go mod tidy
make generate    # regenerate protobuf code (requires protoc + plugins)
```

The test strategy is layered (see
[ADR 0007](docs/adr/0007-testing-strategy.md)):

- **Unit tests** (fixtures) own conversion correctness — run on `make test`,
  gate every PR.
- **`make integration`** (`//go:build integration`) — live scans of
  `scanme.nmap.org` + `contoso.com`, asserting results and FEX/VEX findings.
  Excluded from `make test`; a required CI job.
- **Local vuln-target scans** (`//go:build vulnscan`, **not** in CI) verify that
  predictable vulnerabilities convert correctly:

  ```sh
  make scan-testphp     # nuclei + webbom @ testphp.vulnweb.com
  make scan-juiceshop   # webbom + nuclei @ juice-shop.herokuapp.com
  make integration-vuln # all of the above
  ```

  Some assertions are expected to fail today and surface gaps to fix (e.g.
  webbom is HTTPS-only, so it can't scan HTTP-only testphp).

### Debugging conversions

To verify that a module's output converts losslessly into what mhunt uploads to
Mondoo, pass the hidden `--debug-dir <path>` flag. For every module and target it
writes the raw tool output alongside the converted artifacts, so you can diff the
three by hand:

```sh
mhunt scan example.com --incognito --debug-dir /tmp/dbg
```

```
/tmp/dbg/<target>/<module>.raw.<ext>      # the tool's full, unscoped output
/tmp/dbg/<target>/<module>.findings.json  # converted FEX/VEX (the protojson uploaded)
/tmp/dbg/<target>/<module>.assets.json    # discovered inventory assets
/tmp/dbg/<target>/<module>.sbom.json      # the SBOMs sent via BulkUploadSbom
```

The raw dump is the tool's complete output, never a trimmed view: external-tool
modules write their native format (`nmap.raw.xml`, `nuclei.raw.jsonl`,
`naabu.raw.jsonl`), and the rest write their full result struct as
`<module>.raw.json`.

The dump uses the same converters as the upload path, so it records exactly what
would be sent — and it runs even with `--incognito` (or no credentials), so you
can inspect the conversion without touching Mondoo. It is best-effort: write
errors are logged to stderr and never fail the scan. The flag is hidden because
it is a debugging aid, not part of the everyday command surface.

### Cross-compilation

The binary is pure Go (`CGO_ENABLED=0`, `modernc.org/sqlite`), so cross-builds
need no C toolchain:

```sh
make xc OS=linux ARCH=arm64   # single target -> dist/mhunt_linux_arm64
make build/cross              # all linux/darwin/windows amd64+arm64 targets
```

Release artifacts are produced with GoReleaser via `.goreleaser.yml`.

### Protobuf

The Finding Exchange (FEX) / Vulnerability Exchange (VEX) contract is consumed
from the Mondoo provider SDK (`go.mondoo.com/mql/v13/providers-sdk/v1/upstream/fex`)
and re-exported via `internal/fex/contract.go`; the mhunt-specific `VexSourceName`
enum lives in `internal/fex/vexsource.go`. Other protobuf definitions live in
`internal/recon/webbom/proto/webbom/v1/webbom.proto`. Regenerating those requires
`protoc` with the Go and Ranger RPC plugins (see the `go:generate` directives in
those packages).

## Layout

```
cmd/mhunt             CLI entrypoint
internal/recon/cli     cobra command wiring (scan orchestrator + subcommands)
internal/recon/module  Module abstraction: Target, Registry, runner, output,
                       and one adapter per recon module
internal/recon/<tool>  the recon workers (aws, azure, gcp, shodan, dangling, webbom)
internal/fex           FEX document model, upload, and converters
```

Each recon module implements `module.Module`; the runner drives them uniformly
and the `module.FindingsReporter` interface centralizes FEX/VEX upload. See
`docs/adr/0001-unified-recon-scan-orchestrator.md` for the design. Implementation
packages live under `internal/` so they cannot be imported from outside this
module.
