Skip to content
BreachPilot

Recon — Pipeline (tools/recon/)

Canonical recon implementation. tools/recon_pipeline.py is a deprecated shim re-exporting these modules. ReconConfig.from_config is the sole config entry point. See the family overview for enrichers, OSINT, diff, fast preset, socket fallback, and privilege handling.

Architecture

ReconPipeline.recon_host(target)            # pipeline.py:36
  → preflight probe (opt-in)                # probe_reachable; False+large sample = skip, None/small = proceed
  → PrimaryReconScanner.scan_host(target)   # scanner.py:160
      nmap → rustscan+nmap → masscan+nmap → python socket_scan
  → SecondaryEnumerator.enumerate_host(result)  # enumerator.py:33 (only if open_ports and parallel_secondary)
      per-service-family coroutines, semaphore-bounded, mutate result in place
  → get_attack_surface_summary(result)      # pipeline.py:113 (find_modules top-10)

Standalone additive path: ReconPipeline.recon_udpPrimaryReconScanner.recon_udp_run_nmap_udp (UDP only, no TCP scan, no secondary enumerators).

Package map

FileRoleCanonical for
pipeline.pyReconPipeline orchestratorrecon_host, recon_hosts, recon_udp, get_attack_surface_summary
scanner.pyPrimary scanning + run_commandPrimaryReconScanner
enumerator.pyService-aware deep enumerationSecondaryEnumerator
config.pyData structures + configServiceInfo, HostReconResult, ReconConfig, ToolAvailability

ReconPipeline (pipeline.py:23)

def __init__(self, config: ReconConfig | None = None) -> None
async def recon_host(self, target: str) -> HostReconResult
async def recon_hosts(self, targets: list[str]) -> list[HostReconResult]
async def recon_udp(self, target: str, top_ports: int | None = None) -> HostReconResult
def get_attack_surface_summary(self, result: HostReconResult) -> dict[str, Any]

Lifecycle of recon_host:

  1. Optional preflight (preflight_probe): bare TCP probe_reachable on preflight_ports before the expensive scan. False (all refused) skips the full scan only when the probe set is at least COMMON_PORTS-sized; small-sample refused and None (timeout/filtered ambiguity) fall through to the normal scan. A skipped host returns an empty HostReconResult with an error note.
  2. Primary: self._primary.scan_host(target). Empty open_ports returns early (no secondary enumeration).
  3. Secondary: self._secondary.enumerate_host(result) when parallel_secondary is true.
  4. scan_duration clamped to a minimum of 0.0001s.

recon_hosts runs recon_host per target under asyncio.gather with return_exceptions=True. recon_udp defaults top_ports to udp_top_ports and delegates to the primary scanner. get_attack_surface_summary groups services by name and emits services_by_name, high_value_targets (ssh/rdp/smb), credential_targets, web_targets (http/https with headers/dirs/vulns from svc.scripts), lateral_movement_targets (smb/ldap), privilege_escalation_hints (docker ports 2375/2376/10250), and recommended_attack_modules (top 10 from find_modules).

PrimaryReconScanner (scanner.py:154)

def __init__(self, config: ReconConfig) -> None
async def scan_host(self, target: str) -> HostReconResult
async def recon_udp(self, target: str, top_ports: int = 100) -> HostReconResult
async def _run_nmap(self, target: str) -> HostReconResult | None
async def _run_nmap_udp(self, target: str, top_ports: int = 100) -> HostReconResult | None
async def _run_rustscan(self, target: str) -> HostReconResult | None
async def _run_masscan(self, target: str) -> HostReconResult | None
MethodLineDescription
scan_host160Pyramid: nmap → rustscan+nmap → masscan+nmap → native socket scan; first stage with open_ports wins, errors accumulate
_run_nmap241Comprehensive TCP (-sS -sV -O -Pn -T4 --script=vuln,default -p- -oX -; stealth/aggressive variants by aggression_level); apply_nmap_privilege up front, one downgraded retry on is_privilege_error when priv_fallback is off; XML parse with grepable fallback; TTL → os_family
_run_nmap_udp / recon_udp337 / 439-sU -sV -Pn --top-ports N --script=default,vuln -oX -, parsed by parse_udp_nmap_output into protocol="udp" services + udp_ports; privilege retry uses a halved port set; recon_udp returns an error result when nmap is missing
_run_rustscan455Port discovery (-a target -t 2000 -b 1000 --range 1-65535) + targeted nmap service follow-up (top 50 ports); ports-only fallback when nmap fails
_run_masscan526-p1-65535 --rate 1000 --wait 5 -oJ - + nmap follow-up when available
_parse_nmap_xml / _parse_nmap_grepable601 / 689XML (hostname, MAC/vendor, osmatch, per-port service/scripts, hostscripts → extended["hostscripts"]) / grepable -oG fallback; open→services, filtered→filtered_ports
_extract_ports_from_rustscan / _extract_ports_from_masscan712 / 727Open host:port (+ port -> Open) / per-line JSON with regex fallback
_ttl_to_os_family750≤64 Linux/Unix, ≤128 Windows, ≤255 Cisco/Network

Command runner:

async def run_command(cmd: list[str], *, timeout: int = 300, max_retries: int = 2,
                      retry_delay: float = 5.0, cwd: Path | None = None,
                      env: dict[str, str] | None = None,
                      capture_output: bool = True) -> tuple[bool, str, str, float]

Retries with exponential backoff (retry_delay *= 1.5) but short-circuits privilege errors (is_privilege_error) and _NON_RETRYABLE_EXIT_CODES (127, 126, 9009, 3221225477/86/76). Timeout kills the whole process group via _kill_process. Returns (success, stdout, stderr, elapsed).

SecondaryEnumerator (enumerator.py:27)

def __init__(self, config: ReconConfig) -> None
async def enumerate_host(self, primary_result: HostReconResult) -> HostReconResult

enumerate_host builds one coroutine per detected service family, wraps each in a semaphore gate (max_concurrent_secondary), and gathers with return_exceptions=True. Coroutines mutate the shared result in place (no merge step); exceptions become result.errors entries. Families:

CoroutineTriggerTools / output
_enumerate_http (+ _enumerate_http_service)http/https/http-proxyNikto, feroxbuster (gobuster fallback), nuclei, curl headers → svc.scripts[nikto/feroxbuster/gobuster/nuclei/http_headers], svc.technologies, evidence_refs
_enumerate_sshsshnmap ssh2-enum-algos,ssh-hostkey,ssh-auth-methods + weak-cipher warnings + hydra_ready hint + _map_openssh_cvesopenssh_cves
_enumerate_smbmicrosoft-ds/smb/netbios-ssn/netbios-nsenum4linux (null-session check), smbclient shares (_extract_smb_shares), nmap smb-enum-*,smb-vuln-*
_enumerate_ldapldap/ldaps/globalcatldapldapsearch anonymous bind, nmap ldap-search,ldap-rootdse
_enumerate_ftpftpcurl anonymous login, nmap ftp-anon,ftp-vsftpd-backdoor,…
_enumerate_redisredisDirect nc argv-list INFO probe (no shell); validates target via validate_ipv4/is_fqdn first
_enumerate_elasticsearchelastic*curl _cluster/health + _cat/indices
_enumerate_docker_k8sports 2375/2376/6443/10250/10255/30000curl docker /version, k8s /api, kubelet /pods
_enumerate_rdpms-wbt-server/rdp/terminal-servernmap rdp-enum-encryption,rdp-vuln-ms12-020, NLA-disabled warning

Additive extended_enumerators block (production default on via from_config; dataclass default off):

CoroutineTriggerOutput
_enumerate_tlsTLS-likely ports/servicesnmap ssl-cert,ssl-enumsvc.ssl_info via parse_tls_info
_enumerate_smtpsmtp/smtps or ports 25/465/587nmap smtp-commands,smtp-open-relaysvc.smtp_info via parse_smtp_banner
_enumerate_dbDB ports/namesnmap banner,defaultsvc.db_info via parse_db_banner
_enumerate_web_spiderhttp/httpshttp_spider (bounded BFS, asyncio.to_thread) → result.spider_results
_enumerate_osintalways (once per host)run_osint (passive) → result.osint, result.ipv6_addresses

Depth flags (each opt-in, writes result.extended[key], never raises): _enumerate_subdomains (crt.sh → extended["subdomains"]), _enumerate_vhosts (Host rotation → extended["vhosts"]), _enumerate_waf (header heuristics → extended["waf"]), _enumerate_asn_whois (RDAP → extended["asn"]), _enumerate_cloud_metadata (operator-box IMDS probe → extended["cloud_metadata"]), _enumerate_snmp (snmpwalk public → extended["snmp"]), _enumerate_dns_zone_transfer (dig AXFR → extended["dns_zone"]). HTTP ones take injectable fetch_fn, subprocess ones run_fn, so tests need no live network. Implementation note: per-service fan-out comments in the source describe serial-to-parallel refactors; exact concurrency bounds follow the shared semaphore above.

ReconConfig keys (config.py:199)

@classmethod
def from_config(cls, config: dict | None, **overrides: Any) -> "ReconConfig"

from_config reads the nmap section (path/sudo/priv_fallback) and the recon section (everything else); explicit kwargs win. _concurrency_from_config resolves recon.max_concurrent_secondary, else recon.fast.service_concurrency, default 3, minimum 1.

KeyDataclass defaultfrom_config defaultEffect
nmap.path / sudo / priv_fallbacknmap / false / truesameScanner binary, sudo -n prefix, -sS/-O-sT downgrade
recon.timeout_seconds / max_retries / retry_delay300 / 2 / 5.0samerun_command budget for nmap paths
recon.aggression_levelnormaloverride onlystealth/normal/aggressive nmap argv
recon.wordlist_pathdirb common.txtoverride onlyferoxbuster/gobuster wordlist
recon.fallback_enabledtrueoverride onlyRustscan/masscan/socket fallback chain
recon.parallel_secondary / max_concurrent_secondarytrue / 33 via _concurrency_from_configSecondary fan-out on/off + semaphore bound
recon.udp_top_ports100override onlyrecon_udp --top-ports N
recon.extended_enumeratorsfalsetrueTLS/SMTP/DB/spider/OSINT block
recon.shodan_api_key (+ SHODAN_API_KEY env)""env fallbackEmpty disables Shodan in run_osint
recon.subdomain_enum / vhost_discovery / waf_fingerprint / asn_whois / cloud_metadata_probe / snmp_enum / dns_zone_transferfalsefalseDepth enumerators, each independently gated
recon.preflight_probe / preflight_ports / preflight_timeout_msfalse / [80, 443] / 1000samePre-scan reachability probe

Data structures: ServiceInfo (port, protocol, service, version, banner, cpe[], scripts{}, ssl_info, smtp_info, db_info, os_guess, confidence, technologies[], to_dict/from_dict tolerant of missing keys); HostReconResult (target_ip, hostname, os_name/family/accuracy, ttl, mac/vendor, services[], open/filtered/udp_ports[], scan_duration/tool/raw_outputraw_output dropped on from_dict round-trip for resume — evidence_refs, errors/warnings, spider_results[], osint{}, ipv6_addresses[], extended{}, plus get_services_by_name/port, has_service/port); ToolAvailability.check(tool_name) (shutil.which cache) / reset().

Examples

from tools.recon.config import ReconConfig
from tools.recon.pipeline import ReconPipeline

pipeline = ReconPipeline(ReconConfig.from_config(config, aggression_level="normal"))
result = await pipeline.recon_host("10.0.0.50")
udp = await pipeline.recon_udp("10.0.0.50")          # additive UDP pass only
summary = pipeline.get_attack_surface_summary(result) # high-value/web/cred targets + top-10 modules

multi = await ReconPipeline().recon_hosts(["10.0.0.50", "10.0.0.51"])
from tools.recon.config import ReconConfig

cfg = ReconConfig.from_config(
    {"nmap": {"sudo": True}, "recon": {"extended_enumerators": False}},
    aggression_level="stealth",
)

Source map

  • tools/recon/pipeline.py
  • tools/recon/scanner.py
  • tools/recon/enumerator.py
  • tools/recon/config.py
  • tools/recon_pipeline.py
  • tools/recon_enrichers.py
  • tools/recon_osint.py
  • tools/socket_scan.py
  • tools/nmap_priv.py
source: repo docs (build sync)Edit this page on GitHub →