VHost & SNI Enumeration

The VHost & SNI module discovers hidden virtual hosts living on each target IP. Modern infrastructure routinely puts dozens of distinct applications behind a single IP — admin panels, staging environments, internal APIs, k8s ingress backends — and DNS rarely advertises all of them. The module probes every candidate hostname against every target IP at two layers (HTTP Host: header and TLS SNI) and flags responses that diverge from the bare-IP baseline.

It runs as GROUP 6 Phase A (parallel with Nuclei, GraphQL Security, and Subdomain Takeover). Disabled by default. Enable in the project settings under the VHost & SNI Enumeration tab.


Why two layers

Web routing happens at two different points in the request lifecycle, and the ecosystem is split across both:

LayerWhat we lie aboutCatchesMisses
L7 (HTTP Host header)The HTTP Host: header sent inside the TLS tunnelClassic Apache/Nginx vhosts that route on the application layerModern reverse proxies that decide where to send the connection before parsing HTTP
L4 (TLS SNI)The hostname in the TLS ClientHello (server_name extension)k8s ingress controllers, NGINX-ingress, Traefik, Cloudflare, AWS ALB SNI listeners — anything that routes at the TLS handshakePlain Apache vhosts that only look at Host:

Sending only one of the two leaves whole classes of hidden infrastructure undetected. The module sends both per candidate, so coverage compounds. When the L7 and L4 responses disagree on the same hostname, that itself is a high-severity finding (host_header_bypass) — a primitive for bypassing edge controls that filter at one layer but not the other.


Pipeline position

GROUP 5  - Resource enumeration (Katana, Hakrawler, jsluice, FFuf, GAU, Kiterunner, Arjun)
GROUP 5b - JS Reconnaissance
GROUP 6 Phase A  - Nuclei  ||  GraphQL Security  ||  Subdomain Takeover  ||  VHost & SNI    <- parallel fan-out
GROUP 6 Phase B  - MITRE enrichment (consumes Nuclei CVEs)

Phase A tools are launched via ThreadPoolExecutor. Each scanner uses an _isolated wrapper that deep-copies the shared combined_result so the four threads never race on the same dict. VHost & SNI pulls inputs from earlier groups (port_scan IPs/ports, http_probe TLS SAN data, DNS resolver map, ExternalDomain co-residency).

Wall-time cost when added to a full scan: typically zero. Nuclei dominates this group at 5-30 minutes, while VHost & SNI completes in 5-15 seconds for a single-IP target with the default wordlist.


Target collection

The module assembles a per-IP scan plan from combined_result:

LayerSource in recon_dataPurpose
IP targetsport_scan.by_host[*].ip + ports list. Falls back to dns.subdomains[*].ips.ipv4 with default 80/443 when port_scan didn't runEach IP gets its own baseline + candidate sweep
Per-IP candidate hostnamesSix in-scope sources merged + deduped per IP (see below)The list of hostnames to test against the IP

Candidate sources per IP

For each IP, the module merges hostnames from up to seven sources, lowercases everything, drops duplicates, and filters via _is_valid_hostname:

  1. Subdomain nodes that resolve to this IP (via RESOLVES_TO)
  2. ExternalDomain nodes that resolve to the same IP — out-of-scope subdomains co-resident on the IP, often the highest-signal source
  3. TLS Subject Alternative Names captured on every BaseURL served from this IP — wildcard *.acme.com is stripped to acme.com
  4. CNAME targets from DNSRecord entries
  5. Reverse-DNS PTR record stored on the IP node
  6. Default wordlist (~2,380 curated admin/dev/staging/internal/modern-stack prefixes shipped with the recon image, expanded as {prefix}.{target_apex})
  7. Custom wordlist uploaded per-project — bare prefixes are expanded against the apex, full hostnames (containing a dot) are used as-is

The combined set is capped at VHOST_SNI_MAX_CANDIDATES_PER_IP (default 2,000) per IP. Excess candidates are dropped deterministically (sorted alphabetically) so reruns hit the same set.


Probe sequence (per IP × port)

For every (IP, port) pair, the module runs one baseline plus two probes per candidate hostname:

Step 1 — Baseline

Single curl against the bare IP with no Host override. Records {status, size}.

curl -sk -o /dev/null -w "%{http_code} %{size_download}" \
     --connect-timeout 3 --max-time 9 \
     https://1.2.3.4:443/

If this fails (TCP refused, TLS handshake fails, curl status code 0), the entire (IP, port) is skipped with a warning. There is no point comparing candidate responses against a missing baseline.

Step 2 — L7 probe (Host header trick)

For each candidate hostname, override the HTTP Host header while still dialling the bare IP:

curl -sk -o /dev/null -w "%{http_code} %{size_download}" \
     -H "Host: admin.example.com" \
     --connect-timeout 3 --max-time 9 \
     https://1.2.3.4:443/

The TLS handshake carries SNI = 1.2.3.4 (the URL hostname), but the HTTP request inside the tunnel claims admin.example.com. Apache/Nginx vhosts that route on Host: will hand back the matching backend.

Step 3 — L4 probe (TLS SNI trick)

Only runs on HTTPS ports. Uses --resolve to skip DNS while sending the candidate hostname as the TLS SNI:

curl -sk -o /dev/null -w "%{http_code} %{size_download}" \
     --resolve "admin.example.com:443:1.2.3.4" \
     --connect-timeout 3 --max-time 9 \
     https://admin.example.com:443/

The TCP connection still goes to 1.2.3.4, but the ClientHello carries server_name = admin.example.com and curl naturally sets the matching Host: header. Reverse proxies that decide which backend to talk to before reading any HTTP — k8s ingress controllers, Traefik, Cloudflare, NGINX-ingress — will route to the admin backend.

L7 and L4 probes for all candidates run concurrently per port via ThreadPoolExecutor(max_workers=VHOST_SNI_CONCURRENCY). Each probe gets a --connect-timeout of VHOST_SNI_TIMEOUT seconds with a hard --max-time triple that, plus a Python-side subprocess timeout adding two seconds of grace.

Step 4 — Anomaly detection

For every probe, compare the response to the baseline:

ConditionVerdict
Status code differs from baselineanomaly
Status matches AND |size_observed - size_baseline| > VHOST_SNI_BASELINE_SIZE_TOLERANCE (default 50 bytes)anomaly
Otherwisematch — no finding

The size tolerance suppresses noise from Set-Cookie nonces, CSRF tokens, and per-request timestamps that legitimately change the body byte count without changing the underlying response.


Severity classification

Each anomaly is classified by _classify_severity into one of four tiers:

SeverityTriggerVulnerability type
highL7 and L4 disagree on the same hostname (different status codes OR different size). One layer routed to a different backend than the other — a proxy-bypass primitivehost_header_bypass
mediumThe discovered hostname matches an internal-keyword pattern (admin, staging, internal, jenkins, vault, k8s, argocd, phpmyadmin, etc. — see INTERNAL_KEYWORDS)hidden_vhost (L7-only) or hidden_sni_route (L4-only)
lowDifferent status code than baseline, no internal-keyword match(same as above)
infoSame status code, only the body size differs beyond tolerance(same as above)

Internal-keyword matching is deterministic: when a label matches multiple keywords (e.g. admin-portal matches both admin and portal), the longest keyword wins (lexicographic tiebreak), making the classification stable across reruns.


Discovered URLs feed downstream tools

When VHOST_SNI_INJECT_DISCOVERED is on (default), each confirmed hidden vhost is added as a fresh entry in combined_result["http_probe"]["by_url"] with discovery_source: "vhost_sni_enum" and live: true. This means:

  • Subdomain Takeover (sister tool in the same Phase A group) sees the new alive URLs in its _collect_alive_urls() pass, even though they were discovered after Group 4 finished.
  • Follow-up partial recon runs (Katana, Nuclei) pick up the new BaseURL nodes from the graph and crawl/scan the discovered surface without rerunning the full pipeline.

The injection never overwrites existing by_url entries — already-known URLs are left untouched.


Tools used

ToolWhereNotes
curlRecon container base imageStandard Linux curl. Every probe is a single subprocess.run([...]) with the argument list — no shell, no string interpolation into a command line, no injection surface
httpxProjectDiscovery, already pulled by GROUP 4Used only in partial recon mode to filter the user's subdomain candidates for liveness; not used in full-pipeline mode

No new Docker image, no new pip dependency, no new API key. The module is intentionally lightweight.


Hostname injection safety

The candidate hostname pipeline ends with _is_valid_hostname (RFC-1123 label regex anchored with \Z so newlines cannot slip past $). The validator rejects:

  • Colons (:) — would corrupt --resolve "<host>:<port>:<ip>" syntax
  • Newlines (\n) — would inject HTTP headers
  • Spaces, quotes, backticks, dollar signs, backslashes, NUL bytes — defence-in-depth even though subprocess.run with a list bypasses the shell
  • Labels longer than 63 chars or hostnames longer than 253 chars
  • Underscores (_) and characters outside [A-Z0-9-]
  • Labels starting or ending with -

A malformed entry — whether it landed via the user's custom wordlist, a corrupted graph node, or a TLS SAN with weird shape — is silently dropped before it reaches curl.


Concurrency model

LayerParallelismConfigurable
IP iterationSequentialNo (deliberate — preserves ordering for deterministic logging)
Port iteration per IPSequentialNo
Candidate × layer probes per portConcurrent via ThreadPoolExecutorYes (VHOST_SNI_CONCURRENCY, default 20)
Phase A fan-outConcurrent (vhost_sni runs alongside Nuclei, GraphQL, Takeover)No (handled by the orchestrator)

The per-IP serial design is deliberate: a typical scan has a handful of IPs, so the savings from inter-IP parallelism are small, while the deterministic single-IP reporting makes log triage easier. Within an IP, candidate-level concurrency dominates the total wall time.

A test pinned at VHOST_SNI_CONCURRENCY=8 confirms the module never exceeds the configured cap (max inflight ≤ concurrency + 2 thread-scheduling buffer).


Default wordlist

recon/wordlists/vhost-common.txt ships with the recon image. 2,380 unique entries organised into eleven topical sections:

  • Admin / management surfaces (admin, manage, console, panel, ...)
  • Dev / staging / pre-prod (dev, staging, qa, uat, preprod, ...)
  • CI/CD / build / deploy (jenkins, gitlab, argocd, harbor, ...)
  • Monitoring / logging (grafana, prometheus, kibana, splunk, ...)
  • Containers / orchestration (kubernetes, k8s, rancher, traefik, ...)
  • Databases / caches (phpmyadmin, pgadmin, redis, mongo, ...)
  • Authentication / SSO (keycloak, okta, vault, ldap, ...)
  • Email / communication (mail, mx, exchange, webmail, ...)
  • File storage / sharing (nextcloud, nas, synology, seafile, ...)
  • Security tools / SOC (splunk, siem, crowdstrike, wazuh, ...)
  • ML / AI / blockchain / IoT (mlflow, kubeflow, geth, ipfs, ...)

Each prefix is expanded as {prefix}.{target_apex} per target IP. Project-specific custom prefixes go into the Custom Wordlist textarea on the project form (stored as a Text column, no length limit).


Full parameter reference

Master toggle

ParameterDefaultDescription
VHOST_SNI_ENABLEDfalseMaster toggle. When off, the module is skipped and recon output contains { "skipped_reason": "disabled" }

Layer toggles

ParameterDefaultDescription
VHOST_SNI_TEST_L7trueRun the HTTP Host-header probe per candidate. Catches classic Apache/Nginx vhosts
VHOST_SNI_TEST_L4trueRun the TLS SNI probe per candidate. Only fires on HTTPS ports. Catches reverse-proxy / k8s ingress / CDN routing

If both are off the module exits with { "skipped_reason": "all_layers_disabled" }.

Candidate sources

ParameterDefaultDescription
VHOST_SNI_USE_GRAPH_CANDIDATEStruePull candidate hostnames from existing graph nodes (Subdomain, ExternalDomain, TLS SANs, CNAMEs, PTR). Highest-signal source
VHOST_SNI_USE_DEFAULT_WORDLISTtrueUse the bundled vhost-common.txt (~2,380 prefixes), expanded as {prefix}.{apex}
VHOST_SNI_CUSTOM_WORDLIST""Optional newline-separated prefixes/hostnames from the project form (Text column, no size cap)
VHOST_SNI_MAX_CANDIDATES_PER_IP2000Hard cap on candidates per IP. Excess entries are dropped deterministically (sorted alphabetically)

Performance / behaviour

ParameterDefaultClampDescription
VHOST_SNI_TIMEOUT3>= 1curl --connect-timeout per probe (seconds). Total per-probe budget is 3× this value
VHOST_SNI_CONCURRENCY20>= 1Parallel probes per (IP, port). Higher = faster, louder
VHOST_SNI_BASELINE_SIZE_TOLERANCE50>= 0Bytes of size delta to ignore when status code matches baseline. Suppresses cookie/timestamp jitter
VHOST_SNI_INJECT_DISCOVEREDtrue-When a hidden vhost is confirmed, create a BaseURL node and add it to http_probe.by_url so downstream tools (Subdomain Takeover, partial-recon Nuclei) pick it up

Output structure

Results live under combined_result.vhost_sni:

{
  "by_ip": {
    "1.2.3.4": {
      "ip": "1.2.3.4",
      "baseline": {"status": 403, "size": 548},
      "candidates_tested": 247,
      "ports_tested": 2,
      "anomalies": [
        {
          "hostname": "admin.example.com",
          "ip": "1.2.3.4",
          "port": 443,
          "scheme": "https",
          "layer": "L7",
          "baseline_status": 403,
          "baseline_size": 548,
          "observed_status": 200,
          "observed_size": 4823,
          "size_delta": 4275,
          "severity": "medium",
          "internal_pattern_match": "admin"
        }
      ],
      "anomaly_count": 3,
      "is_reverse_proxy": true,
      "hosts_hidden_vhosts": true
    }
  },
  "findings": [
    {
      "id": "vhost_sni_admin_example_com_1_2_3_4_443_l7",
      "name": "Hidden Virtual Host: admin.example.com",
      "type": "hidden_vhost",
      "severity": "medium",
      "source": "vhost_sni_enum",
      "hostname": "admin.example.com",
      "ip": "1.2.3.4",
      "port": 443,
      "scheme": "https",
      "layer": "L7",
      "baseline_status": 403,
      "baseline_size": 548,
      "observed_status": 200,
      "observed_size": 4823,
      "size_delta": 4275,
      "internal_pattern_match": "admin",
      "description": "Setting Host: admin.example.com on 1.2.3.4:443 returns a different response than the baseline IP request. The hostname pattern 'admin' suggests an internal/admin application.",
      "discovered_at": "2026-04-25T14:30:00Z"
    }
  ],
  "discovered_baseurls": [
    "https://admin.example.com"
  ],
  "summary": {
    "ips_tested": 5,
    "candidates_total": 1247,
    "anomalies_l7": 3,
    "anomalies_l4": 1,
    "high_severity": 1,
    "medium_severity": 2,
    "low_severity": 1,
    "info_severity": 0
  },
  "scan_metadata": {
    "duration_sec": 12.4,
    "scan_timestamp": "2026-04-25T14:30:00Z",
    "wordlist_default_used": true,
    "wordlist_default_count": 2380,
    "wordlist_custom_count": 0,
    "graph_candidates_used": true,
    "test_l7": true,
    "test_l4": true,
    "size_tolerance": 50,
    "concurrency": 20,
    "timeout": 3
  }
}

finding.id is a deterministic hash of (hostname, IP, port, layer) so reruns update the same Vulnerability node instead of duplicating.


Graph schema

Input nodes (consumed)

The module never queries Neo4j directly during full-pipeline runs — all inputs come from the in-memory combined_result dict. The graph provenance of those inputs is:

Graph nodeProduced byHow this module uses it
IPDNS recon (GROUP 1), Naabu / Masscan (GROUP 3)Target IPs to probe. Pulled from port_scan.by_host[*].ip with fallback to dns.subdomains[*].ips.ipv4. Reverse-DNS PTR (IP.reverse_dns) is also read as a candidate source
PortNaabu / Masscan (GROUP 3), Nmap (GROUP 3.5)Ports to test per IP. Honours an explicit scheme field on the port spec when present (so an HTTPS service on port 9001 doesn't get probed as HTTP)
SubdomainSubdomain Discovery (GROUP 1)First-tier candidate source — every Subdomain that resolves to the target IP is added
ExternalDomainDomain recon — out-of-scope subdomainsCo-residency check: ExternalDomain nodes that resolve to the same IP become candidates. Often higher signal than in-scope Subdomains
BaseURLHttpx (GROUP 4)TLS Subject Alternative Names captured by httpx (tls_subject_alt_names / tls_sans) become candidates. Wildcards *.acme.com are stripped to acme.com
CertificateHttpx (GROUP 4)Same SAN extraction as BaseURL when the Certificate node carries them
DNSRecord (CNAME)DNS recon (GROUP 1)CNAME targets resolving to the target IP become candidates

In Partial Recon, the module instead calls _build_vuln_scan_data_from_graph() which directly reads Subdomain, IP, Port, and BaseURL nodes for the project, plus any user-supplied subdomains/IPs injected by the modal.

Nodes that are not read: Service, Endpoint, Technology, Parameter, Header, CVE, MitreData, Capec, Secret, ThreatPulse, Malware.

Output nodes (produced)

Graph nodeOperationNotes
VulnerabilityMERGE by deterministic idOne node per anomaly. source="vhost_sni_enum", type="hidden_vhost" / "hidden_sni_route" / "host_header_bypass". first_seen set on create, last_seen on every run, so reruns update in place
BaseURLMERGE by urlCreated for every confirmed hidden vhost (discovery_source="vhost_sni_enum") so Katana / Nuclei follow-up partial recon can scan the new surface
Subdomain (defensive)MERGE by nameCreated on demand when a finding's hostname has no existing Subdomain node
HAS_VULNERABILITY relationshipMERGEFrom the discovered Subdomain to the Vulnerability. For host_header_bypass findings the IP also gets the same edge
HAS_BASEURL relationshipMERGEFrom the discovered Subdomain to the new BaseURL

Properties enriched on existing nodes

Subdomain (every Subdomain that became an anomaly):

PropertyValue
vhost_testedtrue
vhost_hiddentrue (only when confirmed hidden)
vhost_routing_layer"L7" / "L4" / "both"
vhost_status_codeobserved status code
vhost_size_deltabytes difference vs baseline
sni_routedtrue if layer is L4 or both
vhost_tested_atISO timestamp

IP (every IP that was probed):

PropertyValue
vhost_sni_testedtrue
vhost_baseline_statusbaseline status code
vhost_baseline_sizebaseline body size
hosts_hidden_vhoststrue if any hidden vhost was confirmed
hidden_vhost_countnumber of hidden vhosts found
is_reverse_proxytrue if at least one L4 anomaly fired (SNI routing differs from default → likely k8s ingress / NGINX-ingress / Cloudflare)
vhost_sni_tested_atISO timestamp

No new node labels are introduced. No new relationship types are introduced. The module deliberately reuses existing types so the graph schema stays narrow.

Anchor attachment precedence

For every finding, the mixin tries three anchor strategies in order:

  1. Match existing Subdomain with {name: hostname, user_id, project_id} and MERGE the HAS_VULNERABILITY edge to it.
  2. For host_header_bypass findings, also attach to the IP — the IP is the vulnerable surface (the routing inconsistency is server-side), so the finding shows up in IP-level dashboards too.
  3. Create a defensive Subdomain with source="vhost_sni_enum" and attach the Vulnerability to it. This guarantees every finding is reachable from the graph page.

Properties written on each Vulnerability

PropertyValue
iddeterministic vhost_sni_<host_sanitised>_<ip>_<port>_<layer>
user_id, project_idtenant isolation
source"vhost_sni_enum"
type"hidden_vhost" (L7-only) / "hidden_sni_route" (L4-only) / "host_header_bypass" (both layers disagree)
namee.g. "Hidden Virtual Host: admin.example.com"
severityhigh / medium / low / info
descriptionhuman summary including the discovered hostname, IP, port, layer, and any internal-keyword match
hostnamelowercased FQDN of the discovered vhost
hostsame as hostname (for query convenience)
iptarget IP that hosts the vhost
porttarget port
scheme"http" / "https"
layer"L7" / "L4" / "both"
baseline_status, baseline_sizethe bare-IP response used as comparison
observed_status, observed_sizethe response with the host/SNI lie applied
size_deltasigned difference in bytes
internal_pattern_matchmatched internal-keyword (e.g. "admin", "jenkins") or null
matched_atthe URL form (https://admin.example.com[:port])
is_dast_findingfalse
first_seen, last_seenISO timestamps

Rules of Engagement

VHost & SNI inherits the project-level RoE applied during GROUP 1 (subdomain discovery) and GROUP 3 (port scanning). Targets already filtered by ROE_EXCLUDED_HOSTS never reach this module because the input lists come straight from port_scan.by_host and dns.subdomains.

The module sends real HTTP traffic to the target, so it is treated as active and respects the same in-scope/out-of-scope rules as the rest of the active-scan tools.


Recon presets

All 22 recon presets have an explicit decision recorded:

PresetVHost & SNIRationale
api-securityenabledAPI gateways often hide behind reverse proxies + SNI routing
bug-bounty-deepenabledBug-bounty staple — hidden vhosts are gold
bug-bounty-quickenabled (graph-only, no default wordlist)Speed-tuned: only data-driven candidates
cloud-exposureenabledk8s ingress / Cloudflare = SNI routing is the deciding factor
compliance-auditenabledhost_header_bypass IS a compliance finding
cve-hunterenabledMore attack surface = more CVE matches
directory-discoveryenabledHidden vhosts are hidden HTTP surfaces (same discovery class as ffuf dirs)
full-active-scanenabled"Every active tool at max intensity"
full-maximum-scanenabled (concurrency 40, max candidates 5,000)Everything maxed
graphql-reconenabledGraphQL endpoints often live on internal vhosts
infrastructure-mapperenabledReveals reverse-proxy / ingress topology
parameter-injectionenabledMore endpoints = more parameters to fuzz
red-team-operatorenabled (L4 off, no default wordlist, concurrency 5)Stealth-tuned: graph-only candidates, low concurrency, L7-only
secret-hunterenabledAdmin panels (hidden vhosts) often leak secrets
subdomain-takeoverenabled (concurrency 30)Aggressive — kindred concept (both expose hidden infrastructure)
web-app-pentesterenabledWebapp pentest staple
full-passive-scanexplicit falsePreset's identity is "no packets to target"
osint-investigatorexplicit false"No active scanning"
stealth-reconexplicit false2,380 probes would be catastrophically slow and noisy
large-networkexplicit falsePer-IP serial loop × thousands of IPs = days
dns-email-securityleave defaultDNS/email-only, not a web preset
secret-minerleave defaultPure JS analysis, no host discovery

The decision matrix above is enforced by a content test in webapp/src/lib/recon-presets/vhost-sni.test.ts — adding a new preset that doesn't make a deliberate decision will fail CI.


Stealth mode

VHost & SNI does not currently apply automatic stealth-mode overrides at the runtime layer. Stealth tuning is handled at the preset layer instead — the red-team-operator preset sets VHOST_SNI_TEST_L4=false, VHOST_SNI_USE_DEFAULT_WORDLIST=false, and VHOST_SNI_CONCURRENCY=5 to keep noise low. The stealth-recon preset disables the module entirely.

If you build a custom stealth preset, the recommended quiet configuration is:

VHOST_SNI_ENABLED=true
VHOST_SNI_TEST_L7=true
VHOST_SNI_TEST_L4=false              # SNI brute is louder
VHOST_SNI_USE_DEFAULT_WORDLIST=false # 2,380 probes = noisy
VHOST_SNI_USE_GRAPH_CANDIDATES=true  # only test what's in the graph
VHOST_SNI_CONCURRENCY=5              # slow + quiet
VHOST_SNI_INJECT_DISCOVERED=true     # still wire findings into the graph

Partial recon

VHost & SNI is available as a Partial Recon tool (tool_id="VhostSni"). The modal accepts two user-input types:

  • Custom subdomains — added as candidate hostnames (must be in scope: equal to apex or end with .<apex>)
  • Custom IPs — added as extra targets to probe (validated as IPv4 or CIDR /24-/32)

Partial-recon run behaviour:

  • VHOST_SNI_ENABLED is force-set to true for the run, overriding the project toggle.
  • settings_overrides from the modal bypass stored project settings for that run.
  • Custom subdomains are resolved via the system resolver; resolved IPs are added as additional targets.
  • Custom IPs without an attachment subdomain become a UserInput node with tool="VhostSni" and a PRODUCED edge to each IP.
  • Graph-existing targets can be included or excluded via the Include existing graph targets checkbox. When both graph and custom inputs are empty, the run exits with a "no IP targets" message.
  • Confirmed findings are written to the graph as if the full pipeline ran, so repeated partial runs converge on the same Vulnerability.id instead of duplicating.

See Recon Pipeline Workflow -- Partial Recon for the modal layout.


Implementation notes

  • No new dependencies. The module uses only curl (already in the recon container base image) and httpx (already pulled by GROUP 4). No requirements.txt change, no new Dockerfile entry, no new API key.
  • Deterministic output across reruns. Set iteration of INTERNAL_KEYWORDS is non-deterministic across Python invocations, so the matcher collects all matches per label and picks the longest (lex tiebreak). Two runs against the same target produce the same finding IDs and the same severities.
  • BOM-safe wordlist loader. The default wordlist is read with encoding="utf-8-sig" so a Windows-edited file with a UTF-8 BOM doesn't corrupt the first entry.
  • Hostname validator uses \Z, not $. Python's $ matches before a trailing \n, which would let evil\n.example.com slip past validation and inject HTTP headers via curl --resolve. The module uses \Z (absolute end of string) explicitly.
  • Scheme override honoured. When the upstream port spec carries an explicit scheme field (e.g. http_probe knows 9001 speaks HTTPS), the module honours it instead of re-deriving from the port number.
  • Subprocess-list invocation. Every curl call is subprocess.run([...], shell=False) with the argument list — no shell, no string interpolation. Defense-in-depth: hostname validation rejects shell metacharacters even though they couldn't reach a shell.
  • Per-port baseline. Each (IP, port) combination gets its own baseline. A failed baseline skips that port only — other ports on the same IP still get scanned.
  • Per-IP serial loop. Inter-IP parallelism is intentionally not exposed; per-port concurrency dominates wall time for typical targets.

Test coverage

The module ships with 282 tests across 11 files (~4,000 lines, ~282/282 passing):

FileContainerFocus
recon/tests/test_vhost_sni_enum.pyorchestrator102 unit + module-integration tests for every helper and the end-to-end runner with mocked curl
recon/tests/test_vhost_sni_smoke.pyorchestrator10 real-curl + real-HTTP-server smoke tests against an in-process multi-vhost server
recon/tests/test_vhost_sni_tls_smoke.pyorchestrator7 tests against a real TLS server with a self-signed cert (validates --resolve actually steers SNI)
recon/tests/test_vhost_sni_edge_cases.pyorchestrator46 hostname-injection-safety, wordlist-encoding-edge, curl-output-parsing, and finding-multiplicity tests
recon/tests/test_vhost_sni_stress.pyorchestrator6 concurrency stress tests (200 candidates, max-inflight cap, no thread leaks)
recon/tests/test_vhost_sni_regression.pyboth28 source-level wiring + settings + pipeline registration tests
recon/tests/test_vhost_sni_partial.pyorchestrator14 partial-recon orchestration tests
recon/tests/test_vhost_sni_graph.pyagent8 graph-mixin tests against real Neo4j (idempotency, multi-tenancy, defensive node creation)
webapp/src/lib/recon-presets/vhost-sni.test.tswebapp34 Zod-schema, RECON_PARAMETER_CATALOG, and per-preset content tests (covers all 22 presets)
webapp/src/components/projects/ProjectForm/vhostSniWiring.test.tswebapp16 workflow-definition + nodeMapping + tooltip wiring tests
webapp/src/lib/report/vhostSniReport.test.tswebapp17 report-data type-shape + risk-score-formula + reportTemplate.ts structure tests
webapp/src/lib/vhostSniPrisma.test.tswebapp4 real Prisma round-trip tests against PostgreSQL (defaults, write-then-read, large text column, snake_case mapping)