External Recon
Passive attack-surface recon: paginated subdomain discovery, org IP footprint with prefix, ASN and city, owner attribution with confidence, CDN de-cloaking, Certificate Transparency and TLS-fingerprint pivots.
On this page (26)
- Quick triage
- Subdomain enumeration (DNS hierarchy)
- Count before you enumerate
- Prefix-targeted discovery (interesting hosts)
- Subdomain discovery from Certificate Transparency
- Mapping the IP footprint
- Map the org's IP footprint
- Enrich each subdomain with IP, city, prefix and ASN
- Pivot an IP to its owning network
- All prefixes announced by the target's ASN
- Owner attribution
- Who operates this host, and how confident is the answer?
- Who registered the apex, and what else did they register?
- Email surface: MX and SPF
- Mail servers for a domain
- SPF authorization tree → trusted senders and IP ranges
- Following the chain: CNAMEs and DNS hierarchy
- CNAME target
- Parent in the namespace
- De-cloaking: find the real origin behind a CDN
- TLS fingerprint pivots
- Lookalike domains for phishing-readiness
- One-shot external profile
- Try it from the shell
- De-cloak the origin behind a CDN or proxy
- Splunk equivalents
External Recon Documentation
You're scoping a target for a sanctioned engagement and you want a full external attack-surface picture before you touch a single packet. These recipes take you to it passively: they read WhisperGraph's pre-joined view of public DNS, BGP, WHOIS, Certificate Transparency and TLS-fingerprint data. No DNS queries hit the target's nameservers, no ports get scanned, nothing lands in their logs. Each recipe is a copy-paste Cypher block against https://graph.whisper.security/api/query.
New to the surface? Start with Getting Started and the Graph Schema. Every query here reads data already in the graph: you're querying Whisper's index, not the target's infrastructure. CHILD_OF (the DNS hierarchy index) is the workhorse; Certificate Transparency (SEEN_IN_CT) adds names that never appear in zone transfers or active brute-forcing, on the hosts that layer covers.
Run it live: Attack-Surface Mapper and Find the real infrastructure behind the CDN each open with a live result you can run on your own indicator. The full Attack Surface & Recon landing lists every runnable workflow for this job.
Key concepts: Attack surface · Subdomain enumeration · Origin-IP discovery · Certificate Transparency · TLS fingerprint · WHOIS.
Quick triage
Subdomain enumeration (DNS hierarchy)
A flat DNS tool gives you one record per lookup; recovering an org's full namespace means guessing names or scraping. The graph already holds the subdomain tree, so walk it, and page through it with SKIP/LIMIT rather than pulling the whole estate at once.
// Direct children of the domain, alphabetical, one page at a time
MATCH (sub:HOSTNAME)-[:CHILD_OF]->(:HOSTNAME {name: "github.com"})
RETURN sub.name AS subdomain
ORDER BY sub.name SKIP 0 LIMIT 15
Returns: subdomain
[{"subdomain": "0.github.com"}, {"subdomain": "000.github.com"}, {"subdomain": "00010011.github.com"}]
CHILD_OF links one label at a time (a.b.example.com → b.example.com → example.com → com), so one hop gets the immediate children. For the whole subtree, walk it with a variable-length pattern; sign in to run it on a large estate:
// All observed descendants, including deep ones, paged the same way
MATCH (sub:HOSTNAME)-[:CHILD_OF*1..]->(:HOSTNAME {name: "github.com"})
RETURN DISTINCT sub.name AS subdomain
ORDER BY sub.name SKIP 0 LIMIT 15
Costs: milliseconds for the one-hop page; the variable-length walk is slower on a deep estate; anchor on the parent and traverse inbound, subdomains sit on the left of the arrow.
Tip. Page with literal numbers,
SKIP 0 LIMIT 15, thenSKIP 15 LIMIT 15. Use theMATCH ()-[:CHILD_OF]->()join form shown here rather than aWHERE (sub)-[:CHILD_OF]->(...)predicate. Names the graph only ever saw as a link target or a WHOIS contact may carry noCHILD_OFedge at all, so treat any enumeration as a floor.
From here, → Count before you enumerate.
Count before you enumerate
Before you start paging, size the target's namespace. Tens of thousands of children usually means a CDN, SaaS tenant root or hosting provider, and changes how you scope everything below.
// How many direct children are indexed for this target?
MATCH (sub:HOSTNAME)-[:CHILD_OF]->(:HOSTNAME {name: "github.com"})
RETURN count(sub) AS subdomain_count
Returns: subdomain_count
[{"subdomain_count": 36146}]
Costs: milliseconds; one anchored hop aggregated; plain count() is the right tool for the order of magnitude.
Tip. Treat the count as a floor, not a census; passive data reflects what's been observed. Swap in
[:CHILD_OF*1..]withcount(DISTINCT sub)for the whole subtree: the gap between the two numbers is the depth of the namespace.
From here, → Prefix-targeted discovery (interesting hosts).
Prefix-targeted discovery (interesting hosts)
Pentesters care about the juicy prefixes: vpn., dev., staging., git., jenkins.. Anchor on the target's children through CHILD_OF, then filter the bounded set with STARTS WITH, which keeps the read indexed.
// VPN-flavored hosts under a specific org
MATCH (sub:HOSTNAME)-[:CHILD_OF]->(:HOSTNAME {name: "github.com"})
WHERE sub.name STARTS WITH "vpn"
RETURN sub.name AS subdomain
LIMIT 25
Returns: subdomain
[{"subdomain": "vpn.github.com"}, {"subdomain": "vpn-covid19.github.com"}, {"subdomain": "vpn-test.github.com"}]
Costs: milliseconds; one anchored hop with a string filter on the child set; the filter runs over the target's children only, never over the whole label.
Tip. Anchor first, filter second. A bare
MATCH (h:HOSTNAME) WHERE h.name ENDS WITH ".example.com"has no anchor and scans the entire label, so write the parent into theCHILD_OFpattern and put the interesting-prefix test in theWHERE. Chain several withOR(STARTS WITH "dev" OR STARTS WITH "staging") for one pass over the candidate list.
From here, → Subdomain discovery from Certificate Transparency.
Subdomain discovery from Certificate Transparency
CHILD_OF covers names the graph has resolved. Certificate Transparency catches the rest: internal-sounding hosts an org put on a public cert (SANs, wildcard siblings, short-lived staging certs) that never show up in passive DNS. Certificate observations are a rolling window of recent issuance, so write the recipe to find its own anchor first, then point it at your target.
// Certificate-Transparency observations, anchored so the recipe cannot rot
MATCH (h:HOSTNAME)-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
WITH h LIMIT 5
MATCH (h)-[:SEEN_IN_CT]->(o:CT_OBSERVATION)
RETURN h.name AS host, o.fqdn AS observed_name,
o.certCount AS certs, o.wildcard AS wildcard
LIMIT 20
Returns: host, observed_name, certs, wildcard
[
{"host": "cyberbrand.org", "observed_name": "*.cyberbrand.org", "certs": 1, "wildcard": true},
{"host": "cyberbrand.org", "observed_name": "cyberbrand.org", "certs": 1, "wildcard": false}
]
To run it against your own target, swap the discovery step for an anchor and keep the rest:
// The same read, anchored on a domain you care about
MATCH (h:HOSTNAME {name: "login.live-int.com"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.fqdn AS observed_name, ct.certCount AS certs,
ct.wildcard AS wildcard, ct.firstSeen AS first_seen, ct.lastSeen AS last_seen
ORDER BY ct.lastSeen DESC
LIMIT 20
Costs: milliseconds; one anchored hop into a coverage-scoped layer; a specific host ages out of the window, so re-anchor from the discovery form when a seed goes quiet.
Empty result: Certificate Transparency coverage is partial and recent. A zero-row result here means Whisper holds no recent certificate observation for that host. It never means the host has a clean certificate history; well-known apexes routinely carry none because nothing about them was issued inside the window. If certificate history is load-bearing for your decision, query a CT log directly (crt.sh or the Google CT API) and come back with the hostnames you find. Zero rows is never a verdict.
Tip. A
*.inobserved_namewithwildcard: truemeans the operator issued a wildcard cert, so every subdomain under it is plausible, even ones passive DNS never saw.firstSeen/lastSeenare epoch milliseconds, which makes a freshly issued certificate on a brand-new lookalike a strong early signal. Certificate data lives on:CT_OBSERVATION, reached throughSEEN_IN_CT; there is no:CERTIFICATElabel.
From here, → Map the org's IP footprint.
Mapping the IP footprint
Map the org's IP footprint
You want the set of IPs an org actually answers from, pulled from its subdomains rather than probed live. Bound the subdomain set before resolving, then collect and count.
// Subdomains -> the IPs they resolve to
MATCH (sub:HOSTNAME)-[:CHILD_OF]->(:HOSTNAME {name: "cloudflare.com"})
WITH sub LIMIT 200
MATCH (sub)-[:RESOLVES_TO]->(ip:IPV4)
RETURN collect(DISTINCT ip.name)[0..15] AS ips, count(DISTINCT ip) AS distinct_ips
LIMIT 1
Returns: ips, distinct_ips
[{"ips": ["104.16.47.63", "104.16.48.63", "104.17.72.14", "104.17.73.14", "104.16.132.229"], "distinct_ips": 29}]
Costs: milliseconds; two single hops with a WITH sub LIMIT 200 between them; raise the bound if results come back sparse.
Tip.
RESOLVES_TOis hostname → IP. Not every subdomain has a resolution record in passive data, so a count of zero means "none observed", not "none exist". For the real origins behind a CDN, usewhisper.originsbelow.
From here, → Enrich each subdomain with IP, city, prefix and ASN.
Enrich each subdomain with IP, city, prefix and ASN
You have a target's subdomains and want the full picture for each: the IP it answers from, where that IP sits, the block it belongs to, and which network announces the block. One traversal carries you from the name down to the ASN. Sign in to run it.
// Subdomain -> IP -> routed prefix + ASN + city, in one pass
MATCH (sub:HOSTNAME)-[:CHILD_OF]->(:HOSTNAME {name: "cloudflare.com"})
WITH sub LIMIT 300
MATCH (sub)-[:RESOLVES_TO]->(ip:IPV4)
OPTIONAL MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(c:CITY)
RETURN sub.name AS subdomain, ip.name AS ip, p.name AS prefix, a.name AS asn, c.name AS city
LIMIT 10
Returns: subdomain, ip, prefix, asn, city
[
{"subdomain": "access.cloudflare.com", "ip": "104.16.47.63", "prefix": "104.16.32.0/20", "asn": "AS13335", "city": "Toronto, CA"},
{"subdomain": "ajax.cloudflare.com", "ip": "104.17.72.14", "prefix": "104.17.64.0/20", "asn": "AS13335", "city": "Toronto, CA"}
]
Costs: milliseconds; a bounded subdomain set, then up to four hops per address; keep the geo and network steps OPTIONAL, CDN and anycast addresses often have no city.
Tip. An IP usually belongs to more than one prefix, a wide covering block and a narrower announced block; joining through
<-[:ROUTES]-(a:ASN)pins the result to the routed prefix and its origin ASN. SwapIPV4forIPV6to follow the v6 records, and add(ip)-[:HAS_COUNTRY]->(co:COUNTRY)if you only need the country. Anchor on a domain whose children actually resolve; the alphabetically first children of some estates are crawler-discovered names with no DNS record, so page past them or addWHERE (sub)-[:RESOLVES_TO]->().
From here, → Pivot an IP to its owning network.
Pivot an IP to its owning network
Once you have IPs, attribute each to the network that announces it: owner, ASN and country in one traversal instead of a whois plus a BGP lookup plus a GeoIP call. Sign in to run it.
// IP → announced prefix → ASN → network name → country
MATCH (ip:IPV4 {name: "140.82.112.3"})-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
MATCH (ip)-[:HAS_COUNTRY]->(c:COUNTRY)
RETURN ap.name AS prefix, a.name AS asn, n.name AS network, c.name AS country
LIMIT 5
Returns: prefix, asn, network, country
[{"prefix": "140.82.112.0/24", "asn": "AS36459", "network": "GITHUB - GitHub, Inc.", "country": "US"}]
Costs: milliseconds; three single hops from an indexed address plus one country hop; an address with no live announcement returns no row.
From here, → All prefixes announced by the target's ASN.
All prefixes announced by the target's ASN
The routed footprint of the network you just attributed is the netblock list to add to scope, and it is one hop off the ASN.
// Every IP prefix announced by an ASN, the full routed footprint
MATCH (a:ASN {name: "AS36459"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
RETURN a.name AS asn, p.name AS prefix
LIMIT 50
Returns: asn, prefix
[{"asn": "AS36459", "prefix": "140.82.112.0/24"}, {"asn": "AS36459", "prefix": "140.82.113.0/24"}]
Costs: milliseconds; one anchored hop; count first with RETURN count(p), large transit ASNs announce thousands of prefixes.
Tip. Anchor on the ASN by
{name: "AS…"}and neverCONTAINSonASN.name; it scans the label and will not finish. The network's routing hygiene (RPKI state, MOAS conflicts, hijack posture) is one anchor away on BGP & RPKI.
From here, → Who operates this host, and how confident is the answer?.
Owner attribution
Who operates this host, and how confident is the answer?
You want to know who is behind a hostname, the operator rather than the brand on the page, and how much to trust the answer. whisper.identify attributes a host to a canonical vendor with a confidence score and the roles the evidence supports.
// Operator identity with a confidence score
CALL whisper.identify(["github.com"])
YIELD host, vendor_id, canonical_name, confidence, roles, host_class
RETURN host, vendor_id, canonical_name, confidence, roles, host_class
Returns: host, vendor_id, canonical_name, confidence, roles, host_class
[{"host": "github.com", "vendor_id": "github", "canonical_name": "Github", "confidence": 0.85,
"roles": ["DNS_OPERATOR", "MAIL_RECEIVER", "ORIGIN_AS"], "host_class": "multi_tenant_user_content"}]
Costs: milliseconds; a procedure call over a list of hosts, no traversal; pass a list even for one host.
Tip.
rolestells you which layers the attribution rests on (DNS operator, mail receiver, origin AS), andhost_classwarns you when the host is a multi-tenant platform, where co-tenancy proves nothing. When identity isn't direct,CALL whisper.walk("www.example.com", 2, 500) YIELD host, nearest_known_vendors, coveragereturns the nearest known operators with a confidence and the channel each was inferred through; read the confidence, don't just take the first row. Full signatures on whisper.identify().
From here, → Who registered the apex, and what else did they register?.
Who registered the apex, and what else did they register?
Ownership data lives on the registrable apex, never on the subdomain, so resolve the apex first (CALL whisper.psl.tldPlusOne("api.status.github.com") YIELD apex returns github.com). Then read the WHOIS edges in one pass, and pivot on the contact email to size the rest of the estate.
// Current registrant, registrar and contacts on the apex
MATCH (d:HOSTNAME {name: "github.com"})-[r]->(o)
WHERE type(r) IN ["REGISTERED_BY", "HAS_REGISTRAR", "HAS_EMAIL", "HAS_PHONE", "HAS_COUNTRY"]
RETURN type(r) AS edge, o.name AS value
LIMIT 10
Returns: edge, value
[
{"edge": "HAS_PHONE", "value": "+14157354488"},
{"edge": "HAS_EMAIL", "value": "hostmaster@github.com"},
{"edge": "REGISTERED_BY", "value": "github hostmaster"},
{"edge": "HAS_REGISTRAR", "value": "iana:292"}
]
// Every domain registered with the same contact email
MATCH (e:EMAIL {name: "hostmaster@github.com"})<-[:HAS_EMAIL]-(d:HOSTNAME)
RETURN count(d) AS portfolio_size
[{"portfolio_size": 231}]
Costs: milliseconds; one anchored hop over the WHOIS edge types, then one inbound hop from the indexed email; anchor on the apex, a subdomain carries none of these edges.
Tip. Registrant email is the cleanest pivot, since it's a single normalized value.
ORGANIZATIONis noisier: WHOIS strings come in unresolved, so "github," and "github hostmaster" are separate nodes for the same company. TheREGISTRARnode'snameis an IANA id likeiana:292, not a display name. Privacy services redact a large share of current WHOIS, so treat a missing registrant as "withheld", not "none", and fall back toCALL whisper.history.whois("github.com"), which often shows an un-redacted registrant from an older snapshot. See whisper.history().
From here, → Mail servers for a domain.
Email surface: MX and SPF
Mail servers for a domain
Mail often lives on different infrastructure than the web tier: a separate IP range to add to scope, and a tell for the email vendor in use.
// MX records. Note the direction: MAIL_FOR points server → domain
MATCH (d:HOSTNAME {name: "cloudflare.com"})<-[:MAIL_FOR]-(mx:HOSTNAME)
RETURN mx.name AS mail_server
LIMIT 20
Returns: mail_server
[{"mail_server": "mxa.global.inbound.cf-emailsecurity.net"}, {"mail_server": "mxa-canary.global.inbound.cf-emailsecurity.net"}]
Costs: milliseconds; one inbound hop from an indexed domain; swap MAIL_FOR for NAMESERVER_FOR to list the nameservers the same way.
From here, → SPF authorization tree → trusted senders and IP ranges.
SPF authorization tree → trusted senders and IP ranges
An SPF record names every IP range and third-party service the org trusts to send as them: a free list of vendor relationships and additional netblocks. There are six SPF mechanisms in the graph: SPF_INCLUDE, SPF_IP, SPF_A, SPF_MX, SPF_EXISTS and SPF_REDIRECT.
// SPF mechanisms declared by a target domain
MATCH (h:HOSTNAME {name: "microsoft.com"})-[r:SPF_INCLUDE|SPF_IP|SPF_A|SPF_MX|SPF_EXISTS|SPF_REDIRECT]->(t)
RETURN type(r) AS mechanism, t.name AS target
LIMIT 25
Returns: mechanism, target
[{"mechanism": "SPF_INCLUDE", "target": "_spf-a.microsoft.com"}, {"mechanism": "SPF_INCLUDE", "target": "_spf-b.microsoft.com"}]
To follow one level of includes and pull their authorized IP ranges, join two explicit hops. Re-anchor a second query on each include if you need to go deeper.
// One level of nested includes + their authorized IP ranges
MATCH (h:HOSTNAME {name: "google.com"})-[:SPF_INCLUDE]->(spf:HOSTNAME)
OPTIONAL MATCH (spf)-[:SPF_IP]->(range)
RETURN spf.name AS spf_record, collect(DISTINCT range.name) AS authorized_ranges
LIMIT 10
Costs: milliseconds; one anchored hop over six edge types, then an optional hop per include.
Tip. The full SPF workup, including the tree laid out by depth, is on Posture Audits.
From here, → CNAME target.
Following the chain: CNAMEs and DNS hierarchy
CNAME target
Deep CNAME chains expose CDN, SaaS and cloud-provider relationships that the surface domain hides. ALIAS_OF is the CNAME edge, hostname to canonical host.
// Follow ALIAS_OF (CNAME) to the immediate canonical host
MATCH (h:HOSTNAME {name: "www.github.com"})-[:ALIAS_OF]->(target:HOSTNAME)
RETURN h.name AS host, target.name AS canonical
LIMIT 10
Returns: host, canonical
[{"host": "www.github.com", "canonical": "github.com"}]
Costs: milliseconds; one anchored hop; if the target is itself an alias, re-anchor on it and follow the next hop the same way.
From here, → Parent in the namespace.
Parent in the namespace
Confirm which zone a deep hostname actually sits in before you scope it: CHILD_OF is child → parent.
// subdomain → immediate parent (CHILD_OF is child → parent)
MATCH (h:HOSTNAME {name: "mail.google.com"})-[:CHILD_OF]->(parent:HOSTNAME)
RETURN h.name AS host, parent.name AS parent
LIMIT 10
Returns: host, parent
Costs: milliseconds; one anchored hop; add [:CHILD_OF*1..3] and nodes(path) to climb to the TLD in one call.
From here, → De-cloaking: find the real origin behind a CDN.
De-cloaking: find the real origin behind a CDN
The classic CDN problem: every subdomain resolves to Cloudflare/Akamai/Fastly anycast IPs, so you never see the origin host. whisper.origins derives candidate origins from MX, SPF, sibling-host and crawl signals, with no active scanning of the origin needed.
// Origin IPs behind the CDN, highest-confidence first
CALL whisper.origins("paypal.com")
YIELD ip, confidence, methods
WHERE confidence >= 0.4
RETURN ip, confidence, methods
ORDER BY confidence DESC
LIMIT 10
Returns: ip, confidence, methods
[
{"ip": "13.226.244.115", "confidence": 0.502, "methods": ["sibling", "links_to"]},
{"ip": "173.0.84.208", "confidence": 0.502, "methods": ["sibling", "links_to"]}
]
Costs: under a second to a few seconds; a procedure running several discovery arms, so slower than the anchored traversals on this page.
methods tells you how each origin was found: mx, spf, a sibling host (sibling), or a leaked web link (links_to). The strongest signal is corroboration, an IP found by more than one method. A single mail-only finding is the weakest, because third-party mail providers serve mail for thousands of unrelated domains. confidence runs from 0.0 to 1.0: sibling-derived and multi-method candidates land above 0.4, a lone mx or lone links_to finding below it, so the filter keeps the corroborated web-origin candidates and skips the lone-mail noise. Run it once without the filter to see where your target's distribution sits before you pick a threshold.
Cross-check candidates against the netblock's operating vendor. A candidate sitting in the org's own space, not the CDN's, is a strong origin signal:
// Which vendor actually operates the address space an IP sits in?
MATCH (ip:IPV4 {name: "104.16.132.229"})-[:DELEGATED_TO]->(v:VENDOR)
RETURN ip.name AS ip, v.name AS vendor
LIMIT 5
[{"ip": "104.16.132.229", "vendor": "cloudflare"}]
DELEGATED_TO is the operating vendor, distinct from the WHOIS owner. It's useful for telling "fronted by Cloudflare" apart from "hosted on the org's own AWS region". See the full signature set in Procedures.
From here, → TLS fingerprint pivots.
TLS fingerprint pivots
A target's edge often presents a consistent JA3/JARM fingerprint. If you've fingerprinted one of their hosts passively, find every other IP in the graph emitting the same fingerprint: sibling infrastructure that shares a TLS stack, even across unrelated-looking domains.
// Other IPs presenting the same TLS fingerprint as a known target IP
MATCH (ip:IPV4 {name: "18.179.114.39"})-[:EMITS_TLS_FINGERPRINT]->(f:TLS_FINGERPRINT)
MATCH (sibling:IPV4)-[:EMITS_TLS_FINGERPRINT]->(f)
WHERE sibling.name <> ip.name
RETURN f.name AS fingerprint, sibling.name AS sibling_ip
LIMIT 25
Returns: fingerprint, sibling_ip
[{"fingerprint": "jarm:29d29d00029d29d00029d29d29d29d4d0c5eed338ce212ffe821a67732ded8", "sibling_ip": "52.68.172.112"}]
Costs: milliseconds; one anchored hop out and one back; a thin layer, so pick the seed from the graph rather than from an incident.
Empty result: TLS-fingerprint coverage is partial. Expect most indicators to return nothing. A zero-row result here means Whisper holds no observation, not that the host shares no infrastructure. To find a live anchor, run
MATCH (ip:IPV4)-[:EMITS_TLS_FINGERPRINT]->(f:TLS_FINGERPRINT) RETURN ip.name, f.name LIMIT 5and pivot from one of those. Zero rows is never a verdict.
Tip. A shared JARM across IPs in different prefixes can reveal a common appliance, load balancer or managed-edge vendor that DNS alone won't surface.
CALL whisper.lookupTlsFingerprint("jarm:…")classifies a hash you already hold.
From here, → Lookalike domains for phishing-readiness.
Lookalike domains for phishing-readiness
For a social-engineering or phishing simulation, you want registered lookalikes of the target: domains an attacker (or you, for the assessment) could weaponize. whisper.variants runs many generation algorithms and returns only the variants that already exist as nodes.
// Registered typosquats / lookalikes of the target
CALL whisper.variants("paypal.com")
YIELD variant, method, exists, confidenceLabel
WHERE exists
RETURN variant, method, confidenceLabel
LIMIT 15
Returns: variant, method, confidenceLabel
[{"variant": "aypal.com", "method": "OMISSION", "confidenceLabel": "high"}, {"variant": "pypal.com", "method": "OMISSION", "confidenceLabel": "high"}]
exists: true means registered, not malicious. Pivot any hit through explain() for a threat verdict and to decide whether it's already in use:
// Threat verdict + evidence for a lookalike you found
CALL explain("paypa1.com")
YIELD score, level, factors, sources
RETURN score, level, factors, sources
Costs: a procedure call each, no traversal; whisper.variants is the generator, explain() the verdict.
Read
coveragebeforeband. Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean"; no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. means unknown, which is a different thing again; malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. and ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. mean there is evidence, whatever the band says.whisper.explaindoes not returncoverageat all. Full contract: Coverage — what we looked at.
Registered lookalike hunting is covered in full under Brand Protection.
From here, → One-shot external profile.
One-shot external profile
Pull the headline attack-surface facts for a target in a single anchored traversal, namespace size and mail tier, the kind of pivot that's several separate flat-tool calls.
// Subdomain count + mail servers for a target, one round-trip
MATCH (target:HOSTNAME {name: "github.com"})
OPTIONAL MATCH (sub:HOSTNAME)-[:CHILD_OF]->(target)
WITH target, count(sub) AS subdomain_count
OPTIONAL MATCH (target)<-[:MAIL_FOR]-(mx:HOSTNAME)
RETURN target.name AS domain,
subdomain_count,
collect(DISTINCT mx.name)[0..5] AS mail_servers
Returns: domain, subdomain_count, mail_servers
[{"domain": "github.com", "subdomain_count": 36146, "mail_servers": ["aspmx.l.google.com", "alt1.aspmx.l.google.com", "alt2.aspmx.l.google.com", "alt3.aspmx.l.google.com", "alt4.aspmx.l.google.com"]}]
Costs: milliseconds; two aggregated single hops separated by a WITH, which stops the subdomain count from multiplying the MX list.
From here, → Subdomain enumeration (DNS hierarchy) to start paging the estate you just sized.
Try it from the shell
The endpoint answers a plain curl, which is enough for a quick subdomain count:
curl -s https://graph.whisper.security/api/query \
-H "Content-Type: application/json" \
-d '{"query":"MATCH (sub:HOSTNAME)-[:CHILD_OF]->(:HOSTNAME {name:\"github.com\"}) RETURN count(sub) AS subdomain_count"}'
A key unlocks whisper.history for passive WHOIS/BGP timelines and the deeper enrichment traversals above. Pass it in the X-API-Key header. For AI-driven recon, point any MCP client at AI & Agents and let the agent walk the graph itself.
De-cloak the origin behind a CDN or proxy
A site fronted by a CDN (Cloudflare, Akamai, Fastly) hides its real hosting. whisper.origins surfaces candidate origin IPs, each with a confidence score and the signals that found it, so you can find the server the CDN is protecting. Runs live:
Higher-confidence rows are stronger origin candidates. Pivot each through the graph (co-hosted domains, threat verdict, routing) to confirm before acting.
Splunk equivalents
For continuous attack-surface monitoring inside Splunk, see Workflows and the owned-domain modular input in Modular Inputs. More copy-paste Cypher lives under Recipes.