Posture Audits
Cypher for DNS and email posture: nameserver/MX inventory with attribution, SPF trees, DKIM signers, DMARC destinations, scorecards.
On this page (26)
- Quick triage
- Authoritative nameserver inventory
- Mail server (MX) inventory
- MX attribution — whose network is your mail on?
- The SPF authorization tree
- Full SPF mechanism breakdown
- Walk the full include chain
- How deep does the SPF include chain go?
- Resolve SPF down to authorized IP ranges
- DMARC & DKIM posture
- Where a domain sends DMARC reports
- Which vendor signs a domain's mail (DKIM)
- Email-authentication posture in one row
- Hierarchy, delegation & discovery
- Walk the domain hierarchy
- Who operates a TLD?
- De-cloak the real origin behind a CDN
- Portfolio batch audits
- Batch nameserver audit
- Batch email-security scorecard
- Lookalike & impersonation defense
- Find registered lookalikes, then check who their mail points at
- Sibling domains on a shared nameserver
- Run it
- Going further
- Splunk equivalents
Posture Audits Documentation
You run authoritative-DNS audits, validate that every sender in an SPF record is one you actually authorized, and chase down DMARC and DKIM posture across a portfolio of domains. Doing that with dig and a spreadsheet means one lookup per record, per domain, and no way to ask "which of these mail servers actually sits on a network I trust?" These recipes take you to the pre-joined answer: WhisperGraph holds DNS, the six-edge SPF authorization tree, MX and nameserver delegation, DKIM signers, DMARC destinations, WHOIS, BGP attribution and threat verdicts in one graph, so a morning of dig calls becomes a single anchored traversal.
Every recipe below is copy-paste against the Cypher/REST endpoint at https://graph.whisper.security/api/query. Anchor on a {name: "..."} lookup, keep a LIMIT, and you're in milliseconds even across billions of edges. New here? Start with Getting Started, the Graph Schema, and the Procedures reference.
Run it live. Every recipe on this page has a guided, browser-runnable version that opens with a result on your own domain, no query to write:
- Check nameserver-hijack / DNS-delegation consistency — list nameservers and flag lame or inconsistent delegation.
- Indicator Enrichment — a flat record card: registrar, registrant, nameservers, mail servers, resolved IPs and ASN, threat verdict, SPF includes, CT observations.
More guided flows live on the DNS & Email Security page.
Direction cheatsheet for this page.
NAMESERVER_FORandMAIL_FORboth point server → domain, so a domain's nameservers/MX are reached backwards:(domain)<-[:MAIL_FOR]-(mx).RESOLVES_TOis hostname → IP.CHILD_OFis child → parent. The six SPF edges,DKIM_SIGNED_BY(to aVENDOR) andDMARC_REPORTS_TO(to aDMARC_RECIPIENT) all point domain → target.
Key concepts: SPF · DMARC · DNS · Passive DNS · Typosquatting · Origin-IP discovery.
Quick triage
Authoritative nameserver inventory
A dig NS tells you the names. It doesn't tell you whether those nameservers are spread across providers for fault tolerance, or all sitting behind one. The graph hands you the inventory, and you pivot from there in the same surface.
// Authoritative nameservers delegated for a domain
MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(d:HOSTNAME {name: "cloudflare.com"})
RETURN ns.name AS nameserver
ORDER BY nameserver
LIMIT 20
Returns: nameserver
[
{"nameserver": "bella.ns.cloudflare.com"},
{"nameserver": "chelsea.ns.cloudflare.com"},
{"nameserver": "graham.ns.cloudflare.com"},
{"nameserver": "jerry.ns.cloudflare.com"},
{"nameserver": "jim.ns.cloudflare.com"}
]
Costs: milliseconds; one inbound hop from an indexed domain; the edge points server → domain, so traverse it backwards from the domain you're auditing.
Read it as posture. Nameservers under a single TLD or a single provider are a single point of failure. Passive data can also carry stale delegations, so a nameserver you no longer recognize is a row to check, not to ignore.
From here, → Mail server (MX) inventory.
Mail server (MX) inventory
The MX set is the first thing a spoofing assessment or an inbound-mail migration needs, and the anchor for every attribution question below.
// Inbound mail servers for a domain
MATCH (d:HOSTNAME {name: "paypal.com"})<-[:MAIL_FOR]-(mx:HOSTNAME)
RETURN mx.name AS mail_server
ORDER BY mail_server
LIMIT 20
Returns: mail_server
[
{"mail_server": "mx1.paypalcorp.com"},
{"mail_server": "mx2.paypalcorp.com"}
]
Costs: milliseconds; one inbound hop from an indexed domain.
Don't stop at the name. The next recipe takes a domain's MX records one hop further, to the IP, prefix, ASN and network owner, to confirm your mail actually lands on infrastructure you trust.
From here, → MX attribution — whose network is your mail on?.
MX attribution — whose network is your mail on?
Flat tooling gives you the MX hostname and stops. To learn that mx-in.g.apple.com resolves into AS714 APPLE-ENGINEERING you'd run a resolve, a whois on the IP and a BGP lookup, then join them by hand. The graph walks MX → IP → announced prefix → ASN → network name in one statement.
// MX → resolved IP → announced prefix → ASN → network owner
MATCH (d:HOSTNAME {name: "apple.com"})<-[:MAIL_FOR]-(mx:HOSTNAME)
WITH mx LIMIT 5
MATCH (mx)-[:RESOLVES_TO]->(ip:IPV4)
WITH mx, ip LIMIT 10
MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
OPTIONAL MATCH (a)-[:HAS_NAME]->(n:ASN_NAME)
RETURN DISTINCT mx.name AS mail_server, ip.name AS ip,
ap.name AS prefix, a.name AS asn, n.name AS network
LIMIT 20
Returns: mail_server, ip, prefix, asn, network
[
{"mail_server": "mx-in.g.apple.com", "ip": "17.171.208.6", "prefix": "17.168.0.0/14", "asn": "AS714", "network": "APPLE-ENGINEERING - Apple Inc."},
{"mail_server": "mx-in-hfd.apple.com", "ip": "17.57.165.2", "prefix": "17.57.164.0/23", "asn": "AS714", "network": "APPLE-ENGINEERING - Apple Inc."},
{"mail_server": "mx-in-sg.apple.com", "ip": "17.23.14.18", "prefix": "17.23.14.0/23", "asn": "AS714", "network": "APPLE-ENGINEERING - Apple Inc."}
]
Costs: milliseconds; four single hops staged with two WITH ... LIMITs, so a domain with a dozen MX records never fans out; keep HAS_NAME optional, a network name is occasionally blank.
The audit question. If a mail server for your brand resolves into an ASN you don't recognize (a marketing platform, a forgotten relay, a filtering gateway in front of the platform you expected) that's the row to investigate. Add
OPTIONAL MATCH (ip)-[:DELEGATED_TO]->(v:VENDOR)if you also want the published SaaS egress attribution, and keep it optional; most mail IPs are not in a published vendor range.
From here, → Full SPF mechanism breakdown.
The SPF authorization tree
Full SPF mechanism breakdown
SPF is six different mechanism types, and a TXT lookup flattens them into one string you have to parse by hand. WhisperGraph models each mechanism as its own edge (SPF_INCLUDE, SPF_IP, SPF_A, SPF_MX, SPF_EXISTS, SPF_REDIRECT), so you get a typed breakdown of exactly what a record authorizes.
// Every SPF mechanism on a domain, by type
MATCH (h:HOSTNAME {name: "cloudflare.com"})
-[r:SPF_INCLUDE|SPF_IP|SPF_A|SPF_MX|SPF_EXISTS|SPF_REDIRECT]->(t)
RETURN type(r) AS mechanism, t.name AS authorizes
ORDER BY mechanism
LIMIT 25
Returns: mechanism, authorizes
[
{"mechanism": "SPF_INCLUDE", "authorizes": "mail.zendesk.com"},
{"mechanism": "SPF_INCLUDE", "authorizes": "stspg-customer.com"},
{"mechanism": "SPF_INCLUDE", "authorizes": "_spf.salesforce.com"},
{"mechanism": "SPF_INCLUDE", "authorizes": "spf.mandrillapp.com"}
]
Costs: milliseconds; one anchored hop over six edge types.
Watch for
SPF_REDIRECT. A redirect replaces your entire policy with another domain's. If that target is permissive, so are you, and the graph lets you walk straight into it (next recipes).
From here, → Walk the full include chain.
Walk the full include chain
include: is recursive: each included domain has its own SPF record with its own includes, and RFC 7208 caps the whole tree at ten DNS lookups, so chains that creep toward that ceiling throw permerror for legitimate mail. Expand the tree one anchored hop at a time when you want every branch: pull the first-level includes, then re-anchor on each and pull its includes.
// Level 1: the domain's direct SPF includes
MATCH (h:HOSTNAME {name: "paypal.com"})-[:SPF_INCLUDE]->(inc:HOSTNAME)
RETURN DISTINCT inc.name AS included_domain
ORDER BY included_domain
LIMIT 25
Returns: included_domain
[
{"included_domain": "3ph1._spf.paypal.com"},
{"included_domain": "3ph2._spf.paypal.com"},
{"included_domain": "3ph3._spf.paypal.com"},
{"included_domain": "3ph4._spf.paypal.com"},
{"included_domain": "aspmx.pardot.com"},
{"included_domain": "pp._spf.paypal.com"},
{"included_domain": "sendgrid.net"}
]
The third-party names in that list, sendgrid.net and aspmx.pardot.com, are the ones that carry the tree deeper, because a sending platform maintains its own include record. Re-anchor on one and repeat; each hop stays an indexed {name: ...} lookup:
// Level 2: includes declared by one of the includes above
MATCH (h:HOSTNAME {name: "sendgrid.net"})-[:SPF_INCLUDE]->(inc:HOSTNAME)
RETURN DISTINCT inc.name AS included_domain
ORDER BY included_domain
LIMIT 25
[
{"included_domain": "ab.sendgrid.net"}
]
Costs: milliseconds per level; one anchored hop each; a level that returns nothing is the end of that branch, not a failure.
Two ways down the tree. Re-anchoring keeps every step an indexed lookup and lets you stop the moment a branch stops branching, which is the right tool for auditing one branch completely. When you want the whole tree laid out by depth in one call, use the bounded walk in the next recipe.
From here, → How deep does the SPF include chain go?.
How deep does the SPF include chain go?
A domain's SPF record delegates to other domains, which delegate again. Laid out by depth, the chain shows how many third parties are authorised to send as the domain, and how close it sits to the ten-lookup ceiling.
// The SPF delegation tree, one row per depth
MATCH p = (:HOSTNAME {name: "paypal.com"})-[:SPF_INCLUDE*1..3]->(t:HOSTNAME)
RETURN length(p) AS hops, collect(DISTINCT t.name)[0..8] AS included
ORDER BY hops
LIMIT 5
Returns: hops, included
[
{"hops": 1, "included": ["aspmx.pardot.com", "3ph1._spf.paypal.com", "3ph2._spf.paypal.com", "3ph3._spf.paypal.com", "3ph4._spf.paypal.com", "pp._spf.paypal.com", "sendgrid.net"]},
{"hops": 2, "included": ["et._spf.pardot.com", "ab.sendgrid.net"]}
]
Costs: milliseconds; a variable-length walk bounded at *1..3 from an indexed anchor; SPF trees are short, so the bound is a safety rail, not a constraint you will hit.
Tip. Grouping by
length(p)turns a flat edge list into the delegation tree, and each level is another set of parties who can send mail as the domain. Keep the bound: an unbounded walk overSPF_INCLUDEfans out badly on a badly-configured record. A domain whose chain reaches depth three is usually one acquisition away from breaking its own mail without anyone noticing.
From here, → Resolve SPF down to authorized IP ranges.
Resolve SPF down to authorized IP ranges
The mechanism that actually authorizes a sender at SMTP time is the IP range. To get there you follow one include hop, then the SPF_IP edges hanging off it. The targets are PREFIX/IPV4/IPV6 nodes: the exact CIDRs allowed to send as the domain.
// Domain → SPF includes → authorized IP ranges
MATCH (h:HOSTNAME {name: "google.com"})-[:SPF_INCLUDE]->(spf:HOSTNAME)
OPTIONAL MATCH (spf)-[:SPF_IP]->(range)
WITH spf, collect(DISTINCT range.name) AS ranges
RETURN spf.name AS spf_record, ranges AS authorized_ranges
LIMIT 20
Returns: spf_record, authorized_ranges
[{
"spf_record": "_spf.google.com",
"authorized_ranges": [
"74.125.0.0/16", "209.85.128.0/17",
"2001:4860:4000::/36", "2404:6800:4000::/36",
"2607:f8b0:4000::/36", "2800:3f0:4000::/36",
"2a00:1450:4000::0/36", "2c0f:fb50:4000::/36"
]
}]
Costs: milliseconds; two single hops from an indexed anchor, the second optional.
Broad ranges are the risk. A
/16in your SPF means any host in that block can send as you. Confirm each range belongs to the provider you actually use; the MX attribution recipe above pivots an address into its ASN owner.
From here, → Where a domain sends DMARC reports.
DMARC & DKIM posture
Where a domain sends DMARC reports
DMARC aggregate reports go to the addresses in the rua= tag. The graph models that as DMARC_REPORTS_TO from the domain to a DMARC_RECIPIENT, so you can confirm reporting is configured and pointed where you expect, not at a stale mailbox or a third party you've offboarded.
// DMARC aggregate-report recipients for a domain
MATCH (h:HOSTNAME {name: "apple.com"})-[:DMARC_REPORTS_TO]->(d:DMARC_RECIPIENT)
RETURN d.name AS dmarc_rua
LIMIT 10
Returns: dmarc_rua
[
{"dmarc_rua": "d@rua.agari.com"}
]
Costs: milliseconds; one anchored hop.
Empty result: no
DMARC_REPORTS_TOedge means no aggregate-reporting address is recorded for the zone. Confirm with a direct TXT lookup before you write it up as "DMARC not deployed"; either way it is a row for the remediation list, not a passing grade. Aruaat a known processor (likerua.agari.com) tells you managed DMARC monitoring is in place.
From here, → Which vendor signs a domain's mail (DKIM).
Which vendor signs a domain's mail (DKIM)
SPF tells you which networks may send; DKIM tells you whose keys actually sign, and the two lists disagree more often than anyone expects. DKIM_SIGNED_BY maps a domain to the mail vendor whose key signs its outbound mail, so you can read signers, SPF includes and DMARC destinations side by side.
// DKIM signers, SPF includes and DMARC destinations, side by side
MATCH (h:HOSTNAME {name: "github.com"})
OPTIONAL MATCH (h)-[:DKIM_SIGNED_BY]->(v:VENDOR)
OPTIONAL MATCH (h)-[:SPF_INCLUDE]->(spf:HOSTNAME)
OPTIONAL MATCH (h)-[:DMARC_REPORTS_TO]->(d:DMARC_RECIPIENT)
RETURN h.name AS domain, collect(DISTINCT v.name) AS dkim_signers,
collect(DISTINCT spf.name)[0..5] AS spf_includes,
collect(DISTINCT d.name)[0..3] AS dmarc_reports_to
LIMIT 1
Returns: domain, dkim_signers, spf_includes, dmarc_reports_to
[{
"domain": "github.com",
"dkim_signers": ["microsoft", "google"],
"spf_includes": ["_netblocks.google.com", "_netblocks2.google.com", "_netblocks3.google.com", "mktomail.com", "spf.protection.outlook.com"],
"dmarc_reports_to": ["dmarc@github.com"]
}]
Costs: milliseconds; three optional single hops from one anchor; keep every step OPTIONAL MATCH, most domains have some of these and few have all three.
Empty result: an empty
dkim_signerslist means no DKIM signer is recorded for the domain, not that its mail is unsigned; confirm with a selector lookup before you report it. Zero rows is never a verdict.
Read the three lists side by side. A vendor that signs with DKIM but appears nowhere in SPF is usually a mail platform someone onboarded without telling the DNS owner, a live shadow-IT finding. Two signers usually means split sending: transactional mail through one provider, marketing through another. An empty
dmarc_reports_tonext to a populated signer list means mail is going out under this domain and nobody is collecting the reports.
From here, → Email-authentication posture in one row.
Email-authentication posture in one row
One anchored query returns the SPF, DMARC and DKIM state of a domain together: whether a policy exists, whether reports are collected, and who signs. It is the single-domain form of the portfolio scorecard below.
// SPF presence + DMARC reporting + DKIM signers in one posture check
MATCH (h:HOSTNAME {name: "stripe.com"})
OPTIONAL MATCH (h)-[:SPF_INCLUDE|SPF_IP|SPF_A|SPF_MX|SPF_EXISTS|SPF_REDIRECT]->(spf)
OPTIONAL MATCH (h)-[:DMARC_REPORTS_TO]->(rua:DMARC_RECIPIENT)
OPTIONAL MATCH (h)-[:DKIM_SIGNED_BY]->(v:VENDOR)
RETURN h.name AS domain,
count(DISTINCT spf) AS spf_mechanisms,
collect(DISTINCT rua.name) AS dmarc_recipients,
collect(DISTINCT v.name) AS dkim_signers
LIMIT 1
Returns: domain, spf_mechanisms, dmarc_recipients, dkim_signers
[{
"domain": "stripe.com",
"spf_mechanisms": 5,
"dmarc_recipients": ["dmarc-reports@stripe.com"],
"dkim_signers": ["google"]
}]
Costs: milliseconds; three optional single hops from one indexed anchor.
Empty result: empty fields mean not configured or not observed; treat them as gaps to verify, not as proof of absence. Zero rows is never a verdict.
How to read the three columns.
spf_mechanisms > 0confirms an SPF policy exists; a non-emptydmarc_recipientsconfirms aggregate reporting;dkim_signersnames who holds signing keys. The combination that should worry you is a populated signer list with no DMARC recipient.
From here, → Walk the domain hierarchy.
Hierarchy, delegation & discovery
Walk the domain hierarchy
CHILD_OF points child → parent, so you can confirm a subdomain sits in the zone you expect and hasn't been delegated away. Walk it as explicit anchored hops; the hierarchy is shallow and each hop stays an indexed lookup.
// Subdomain up to its immediate parent and grandparent
MATCH (h:HOSTNAME {name: "mail.google.com"})-[:CHILD_OF]->(parent:HOSTNAME)
OPTIONAL MATCH (parent)-[:CHILD_OF]->(grandparent)
RETURN h.name AS subdomain,
parent.name AS parent,
grandparent.name AS grandparent
LIMIT 10
Returns: subdomain, parent, grandparent
[
{"subdomain": "mail.google.com", "parent": "google.com", "grandparent": "com"}
]
Costs: milliseconds; two single hops from an indexed anchor.
Going further up. For a longer chain, take the
parentfrom the result and re-run anchored on it, or use a bounded walk:MATCH path = (h:HOSTNAME {name: "mail.google.com"})-[:CHILD_OF*1..3]->(ancestor) RETURN [n IN nodes(path) | n.name]returns each ancestor chain as a list.CHILD_OFis short by nature, so the bound is a latency guard, not a limit you will meet.
From here, → Who operates a TLD?.
Who operates a TLD?
When you escalate abuse or need to understand jurisdiction, you want the registry behind the zone. TLD_OPERATOR-[:OPERATES]->TLD is the authoritative link, and the operator is the fast direction to anchor on.
// Registry operator for a set of TLDs
MATCH (op:TLD_OPERATOR {name: "VeriSign Global Registry Services"})-[:OPERATES]->(tld:TLD)
RETURN op.name AS operator, collect(tld.name) AS tlds
LIMIT 1
Returns: operator, tlds
[{"operator": "VeriSign Global Registry Services", "tlds": ["com", "net"]}]
Costs: milliseconds; one anchored hop; operator names are exact strings, including punctuation.
From here, → De-cloak the real origin behind a CDN.
De-cloak the real origin behind a CDN
Auditing a mail or app host that sits behind Cloudflare or another proxy? The resolved IP is the CDN, not the origin. whisper.origins surfaces the true origin candidates (see Procedures), and methods names the signal each came from: spf candidates are addresses the domain's own SPF record authorizes, so the audit you just ran is what de-cloaks the origin.
// Candidate origin IPs behind the proxy, strongest corroboration first
CALL whisper.origins("bitwarden.com")
YIELD ip, confidence, methods
RETURN ip, confidence, methods
ORDER BY confidence DESC
LIMIT 10
Returns: ip, confidence, methods
[
{"ip": "159.242.240.113", "confidence": 0.5492, "methods": ["spf"]},
{"ip": "159.242.240.114", "confidence": 0.5492, "methods": ["spf"]},
{"ip": "159.242.241.113", "confidence": 0.5492, "methods": ["spf"]}
]
Costs: a few seconds; a procedure that runs several independent discovery arms (MX, SPF, siblings, links), so it is slower than the anchored traversals on this page.
YIELDthe columns you need. The procedure also returns network attribution (asnName) and akind/categorypair for each candidate; on a domain whose origins sit outside the graph's attributed space those columns come back blank for every row, and naming the columns you actually read keeps a blank column out of your report.confidenceruns from0.0to1.0: a candidate corroborated by more than one method scores highest.
From here, → Batch nameserver audit.
Portfolio batch audits
Batch nameserver audit
Run one query across your whole portfolio and spot the outlier: the domain still on a pre-migration provider, or a test domain parked on a registrar default.
// Nameservers across a portfolio, in one pass
UNWIND ["paypal.com", "google.com", "cloudflare.com"] AS domain
MATCH (h:HOSTNAME {name: domain})
OPTIONAL MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(h)
RETURN domain, collect(DISTINCT ns.name) AS nameservers
Returns: domain, nameservers
[
{"domain": "paypal.com", "nameservers": ["ns1-pchnet.paypal.com", "ns2-pchnet.paypal.com", "ppdns.paypal.com", "pdns100.ultradns.com", "ns1.p57.dynect.net", "ns2.p57.dynect.net", "pdns100.ultradns.net"]},
{"domain": "google.com", "nameservers": ["ns1.google.com", "ns2.google.com", "ns3.google.com", "ns4.google.com"]}
]
Costs: milliseconds; one indexed anchor per element plus one optional hop each; swap the list for your real inventory.
From here, → Batch email-security scorecard.
Batch email-security scorecard
One traversal returns an SPF / DMARC / DKIM scorecard for every domain you own: one at-a-glance posture sheet. Each row anchors on an indexed {name: domain} lookup, so a portfolio of hundreds still returns fast.
// SPF + DMARC + DKIM posture across a portfolio
UNWIND ["paypal.com", "google.com", "cloudflare.com", "stripe.com"] AS domain
MATCH (h:HOSTNAME {name: domain})
OPTIONAL MATCH (h)-[:SPF_INCLUDE|SPF_IP|SPF_A|SPF_MX|SPF_EXISTS|SPF_REDIRECT]->(spf)
OPTIONAL MATCH (h)-[:DMARC_REPORTS_TO]->(rua:DMARC_RECIPIENT)
OPTIONAL MATCH (h)-[:DKIM_SIGNED_BY]->(v:VENDOR)
RETURN domain,
count(DISTINCT spf) AS spf_mechanisms,
count(DISTINCT rua) AS dmarc_recipients,
collect(DISTINCT v.name) AS dkim_signers
ORDER BY spf_mechanisms DESC
Returns: domain, spf_mechanisms, dmarc_recipients, dkim_signers
[
{"domain": "cloudflare.com", "spf_mechanisms": 8, "dmarc_recipients": 2, "dkim_signers": ["mandrill"]},
{"domain": "paypal.com", "spf_mechanisms": 7, "dmarc_recipients": 2, "dkim_signers": []},
{"domain": "stripe.com", "spf_mechanisms": 5, "dmarc_recipients": 1, "dkim_signers": ["google"]},
{"domain": "google.com", "spf_mechanisms": 1, "dmarc_recipients": 1, "dkim_signers": []}
]
Costs: milliseconds; one indexed anchor per element and three optional single hops each.
Empty result: an empty
dkim_signerslist or a zerodmarc_recipientscount means nothing is recorded, not that nothing is deployed; verify the gap before it goes in a report. Zero rows is never a verdict.
Scale it. Swap the
UNWINDlist for your real domain inventory. Because each row anchors on an indexed{name: ...}lookup, a portfolio of hundreds still returns fast.
From here, → Find registered lookalikes, then check who their mail points at.
Lookalike & impersonation defense
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. Full contract: Coverage — what we looked at.
Find registered lookalikes, then check who their mail points at
Brand-impersonation domains usually stand up mail before they're used for phishing. Generate the typosquats with whisper.variants, then pivot each registered hit to its MX and threat verdict, all without leaving the graph.
// Registered typosquats of a brand domain
CALL whisper.variants("paypal.com")
Returns: variant, method, exists, nodeId, label, confidence, confidenceLabel
For a found lookalike, check whether it has live mail infrastructure and a threat verdict:
// Does a lookalike have MX, and is it flagged?
MATCH (d:HOSTNAME {name: "paypa1.com"})
OPTIONAL MATCH (d)<-[:MAIL_FOR]-(mx:HOSTNAME)
RETURN d.name AS lookalike,
collect(DISTINCT mx.name) AS mail_servers,
d.verdictLevel AS verdict,
d.verdictBlocking AS should_block,
d.isPhishing AS is_phishing
LIMIT 1
[{"lookalike": "paypa1.com", "mail_servers": ["bh.markmonitor.com"], "verdict": "NONE", "should_block": false, "is_phishing": false}]
Costs: a procedure call for the variants, then one anchored hop per lookalike; whisper.variants returns only variants that exist as nodes.
Identity vs. verdict. A registered lookalike isn't automatically malicious:
whisper.variantsreturns what exists, not what's bad, and a lookalike parked at a brand-protection registrar (as above) is the brand defending itself. TheverdictLevel/verdictBlockingfields andCALL explain("paypa1.com")give the reconciled, evidence-backed answer. See explain() for the full factor breakdown, and the Lookalike Hunting recipes for the brand-protection workflow.
From here, → Sibling domains on a shared nameserver.
Sibling domains on a shared nameserver
A nameserver you control serves a known set of domains. One that shouldn't be there, or a known-bad one sharing infrastructure, surfaces by walking outward from the nameserver.
// All domains a given nameserver is authoritative for
MATCH (ns:HOSTNAME {name: "ns1.google.com"})-[:NAMESERVER_FOR]->(d:HOSTNAME)
WITH d LIMIT 50
RETURN collect(d.name) AS served_domains
Returns: served_domains
Costs: milliseconds; one anchored hop bounded with WITH d LIMIT 50 before the collect; a busy nameserver is authoritative for millions of domains, so raise the bound deliberately, not by accident.
From here, → Authoritative nameserver inventory to close the loop on your own delegation.
Run it
One anchored hop is enough to sanity-check any single-domain recipe on this page:
curl -s https://graph.whisper.security/api/query \
-H "Content-Type: application/json" \
-d '{"query":"MATCH (d:HOSTNAME {name:\"paypal.com\"})<-[:MAIL_FOR]-(mx:HOSTNAME) RETURN mx.name LIMIT 10"}'
For a key of your own, sign in and pass it in the X-API-Key header. To skip the query-writing entirely, run the guided posture flows in the browser.
Going further
- Workflows — recipes and guided workflows organized by security job.
- Graph Schema — every label, edge, and property, including the full SPF/DMARC/DKIM model.
- Procedures —
explain,whisper.variants,whisper.history,whisper.origins. - Threat Feeds & Categories — the feeds and categories behind the reconciled verdict.
- AI & Agents — point an MCP client at
https://mcp.whisper.securityand let an agent run these audits mid-conversation.
Splunk equivalents
For SPF/DMARC posture audits and dangling-DNS detection in SPL, see Workflows. The whisper_spf_chain and whisper_cname_chain macros wrap the same Cypher patterns; see Investigation Macros.