Skip to content
Recipes
Skip navigation
Recipes

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.

Published

View as Markdown
On this page (26)

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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:

cypher · runnablegraph.whisper.securitySign in to run
// 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, then SKIP 15 LIMIT 15. Use the MATCH ()-[:CHILD_OF]->() join form shown here rather than a WHERE (sub)-[:CHILD_OF]->(...) predicate. Names the graph only ever saw as a link target or a WHOIS contact may carry no CHILD_OF edge 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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..] with count(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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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 the CHILD_OF pattern and put the interesting-prefix test in the WHERE. Chain several with OR (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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[
  {"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:

cypher
// 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 *. in observed_name with wildcard: true means the operator issued a wildcard cert, so every subdomain under it is plausible, even ones passive DNS never saw. firstSeen/lastSeen are epoch milliseconds, which makes a freshly issued certificate on a brand-new lookalike a strong early signal. Certificate data lives on :CT_OBSERVATION, reached through SEEN_IN_CT; there is no :CERTIFICATE label.

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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_TO is 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, use whisper.origins below.

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[
  {"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. Swap IPV4 for IPV6 to 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 add WHERE (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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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 never CONTAINS on ASN.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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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. roles tells you which layers the attribution rests on (DNS operator, mail receiver, origin AS), and host_class warns 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, coverage returns 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[
  {"edge": "HAS_PHONE", "value": "+14157354488"},
  {"edge": "HAS_EMAIL", "value": "hostmaster@github.com"},
  {"edge": "REGISTERED_BY", "value": "github hostmaster"},
  {"edge": "HAS_REGISTRAR", "value": "iana:292"}
]
cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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. ORGANIZATION is noisier: WHOIS strings come in unresolved, so "github," and "github hostmaster" are separate nodes for the same company. The REGISTRAR node's name is an IANA id like iana: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 to CALL 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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.

cypher · runnablegraph.whisper.securitySign in to run
// 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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.

cypher · runnablegraph.whisper.securitySign in to run
// 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[
  {"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:

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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 5 and 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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:

cypher · runnablegraph.whisper.securitySign in to run
// 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 coverage before band. 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.explain does not return coverage at 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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

json
[{"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:

bash
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:

Live · graph.whisper.security
read-only Cypher

Copy as
Open it in the Console

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.