Posture Audits
Cypher recipes for DNS and email posture: nameserver and MX inventory, SPF authorization trees, DMARC/DNSSEC checks, and portfolio scorecards.
On this page (25)
- 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
- Resolve SPF down to authorized IP ranges
- DMARC & DNSSEC posture
- Where a domain sends DMARC reports
- Which vendor signs a domain's mail (DKIM)
- DNSSEC signing posture
- 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 DNSSEC 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?" WhisperGraph already holds DNS, the six-edge SPF authorization tree, MX and nameserver delegation, WHOIS, BGP attribution, and threat verdicts — pre-joined — so an audit that used to be 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 all point domain → authorized target.
Quick triage
Authoritative nameserver inventory
A dig NS tells you the names. It doesn't tell you whether those nameservers are spread across resolvers and TLDs for fault tolerance, or all sitting behind one provider. 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
[
{"nameserver": "bella.ns.cloudflare.com"},
{"nameserver": "chelsea.ns.cloudflare.com"},
{"nameserver": "graham.ns.cloudflare.com"},
{"nameserver": "ns3.cloudflare.com"},
{"nameserver": "ns4.cloudflare.com"}
]
Read it as posture. Nameservers under a single TLD or a single provider are a single point of failure. The edge points server → domain, so traverse it backwards from the domain you're auditing.
Mail server (MX) inventory
// 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
[
{"mail_server": "mx1.paypalcorp.com"},
{"mail_server": "mx2.paypalcorp.com"}
]
Don't stop at the name. The next recipe takes each MX one hop further — to the IP, prefix, ASN, and network owner — to confirm your mail actually lands on infrastructure you trust.
MX attribution — whose network is your mail on?
Flat tooling gives you the MX hostname and stops. To learn that mx2.paypalcorp.com resolves into AS1449 PAYPAL-CORP 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: "paypal.com"})<-[:MAIL_FOR]-(mx:HOSTNAME)
-[:RESOLVES_TO]->(ip:IPV4)
-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN mx.name AS mail_server, ip.name AS ip,
ap.name AS prefix, a.name AS asn, n.name AS network
LIMIT 20
[{
"mail_server": "mx2.paypalcorp.com",
"ip": "173.224.161.141",
"prefix": "173.224.160.0/21",
"asn": "AS1449",
"network": "PAYPAL-CORP - PayPal, Inc."
}]
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 third party — that's the row to investigate.
ROUTESandANNOUNCED_BYare written here as explicit single hops; never bare-scan ASNs.
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
[
{"mechanism": "SPF_INCLUDE", "authorizes": "_spf.google.com"},
{"mechanism": "SPF_INCLUDE", "authorizes": "spf.mandrillapp.com"},
{"mechanism": "SPF_IP", "authorizes": "199.15.212.0/22"}
]
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 recipe).
Walk the full include chain
include: is recursive — each included domain has its own SPF record with its own includes. RFC 7208 allows the whole tree 10 DNS lookups, and chains that creep toward that ceiling throw permerror for legitimate mail. Rather than a variable-length walk (which can wander and never come back), expand the tree one anchored hop at a time: pull the first-level includes, then re-anchor on each and pull its includes. Counting the distinct nodes you reach tells you how close to the RFC's ceiling you are.
// 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
[
{"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, 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, so the walk stays fast:
// 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"}
]
A level that returns nothing is the end of that branch, not a failure. Re-anchoring on pp._spf.paypal.com returns no rows at all — no SPF_INCLUDE edge, so nothing further to walk on that arm — which is how you know the count you have is the whole tree.
Why hop-by-hop, not
*1..N. An anchored single hop is an indexed lookup that returns in milliseconds; re-anchoring on each result keeps every step bounded and lets you stop the moment the tree stops branching.
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
[{
"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"
]
}]
Broad ranges are the risk. A
/16in your SPF means any host in 65k addresses can send as you. Confirm each range belongs to the provider you actually use — the MX attribution recipe above pivots a range into its ASN owner.
DMARC & DNSSEC 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
[
{"dmarc_rua": "d@rua.agari.com"}
]
An empty result is a finding. No
DMARC_REPORTS_TOedge means no aggregate reporting is observed for the zone — either DMARC isn't deployed, orruais unset. Either way it's a row for your remediation list, not a passing grade. Aruaat a known processor (likerua.agari.com) tells you managed DMARC monitoring is in place.
Which vendor signs a domain's mail (DKIM)
The DKIM_SIGNED_BY edge maps a domain to the mail vendor whose DKIM key signs its outbound mail, so you can tell at a glance whether a domain sends through Google, Microsoft, or its own infrastructure — and whether that matches the sender it declares in SPF.
// DKIM signing vendor(s) for a domain
MATCH (h:HOSTNAME {name: "github.com"})-[:DKIM_SIGNED_BY]->(v:VENDOR)
RETURN v.name AS dkim_vendor
LIMIT 10
Two vendors usually means split sending — transactional mail through one provider, marketing through another. A DKIM vendor that doesn't match the domain's declared SPF sender is worth a closer look.
DNSSEC signing posture
DNSSEC posture comes back as the signing algorithms in use. A signed zone links to one or more DNSSEC_ALGORITHM nodes; an unsigned zone returns nothing.
// SPF presence + DMARC reporting + DNSSEC signing 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)-->(algo:DNSSEC_ALGORITHM)
RETURN h.name AS domain,
count(DISTINCT spf) AS spf_mechanisms,
count(DISTINCT rua) AS dmarc_recipients,
collect(DISTINCT algo.name) AS dnssec_algorithms
LIMIT 1
[{
"domain": "stripe.com",
"spf_mechanisms": 3,
"dmarc_recipients": 0,
"dnssec_algorithms": []
}]
How to read the three columns.
spf_mechanisms > 0confirms an SPF policy exists;dmarc_recipients > 0confirms aggregate reporting; a non-emptydnssec_algorithmsconfirms the zone is signed. Empty fields mean not configured or not observed — treat them as gaps to verify, not as proof of absence.
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 rather than a variable-length pattern — 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
[
{"subdomain": "mail.google.com", "parent": "google.com", "grandparent": "com"}
]
Re-anchor to go deeper. For a longer chain, take the
parentfrom the result and re-run the query anchored on it. That keeps each hop indexed, and never triggers the variable-length wander.
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.
// 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
[{"operator": "VeriSign Global Registry Services", "tlds": ["com", "net"]}]
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. The whisper.origins procedure surfaces the true origin IPs — see the Procedures reference. methods names the signal each candidate came from, which is why this belongs on a posture page: 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
[
{"ip": "159.242.240.113", "confidence": 0.5492, "methods": ["spf"]},
{"ip": "159.242.240.114", "confidence": 0.5492, "methods": ["spf"]},
{"ip": "18.154.63.41", "confidence": 0.0948, "methods": ["links_to"]}
]
YIELDthe columns you need. The procedure returns network attribution alongside each candidate, and on a domain whose origins sit outside the graph's attributed space those columns come back blank for every row — naming the columns you are actually reading keeps a blank column out of your report. Origin discovery runs several independent arms (MX, SPF, siblings, links), so it takes a few seconds where the anchored traversals on this page return in milliseconds.
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
[
{"domain": "paypal.com", "nameservers": ["ns1.p57.dynect.net", "pdns100.ultradns.com", "pdns100.ultradns.net"]},
{"domain": "google.com", "nameservers": ["ns1.google.com", "ns2.google.com", "ns3.google.com", "ns4.google.com"]},
{"domain": "cloudflare.com", "nameservers": ["ns3.cloudflare.com", "ns4.cloudflare.com", "ns5.cloudflare.com", "ns6.cloudflare.com"]}
]
Batch email-security scorecard
One traversal returns an SPF / DMARC / DNSSEC scorecard for every domain you own — the at-a-glance posture sheet that flat tools make you assemble one dig at a time. Each row anchors on an indexed {name: domain} lookup, so a portfolio of hundreds still returns fast.
// SPF + DMARC + DNSSEC 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)-->(algo:DNSSEC_ALGORITHM)
RETURN domain,
count(DISTINCT spf) AS spf_mechanisms,
count(DISTINCT rua) AS dmarc_recipients,
size(collect(DISTINCT algo.name)) AS dnssec_algorithms
ORDER BY spf_mechanisms DESC
[
{"domain": "cloudflare.com", "spf_mechanisms": 8, "dmarc_recipients": 0, "dnssec_algorithms": 0},
{"domain": "paypal.com", "spf_mechanisms": 7, "dmarc_recipients": 0, "dnssec_algorithms": 0},
{"domain": "stripe.com", "spf_mechanisms": 3, "dmarc_recipients": 0, "dnssec_algorithms": 0},
{"domain": "google.com", "spf_mechanisms": 1, "dmarc_recipients": 0, "dnssec_algorithms": 0}
]
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.
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.whisper.explaindoes not returncoverageat all. 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")
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
Identity vs. verdict. A registered lookalike isn't automatically malicious —
whisper.variantsreturns what exists, not what's bad. 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.
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
Bound high-fan-out walks. A busy nameserver can be authoritative for millions of domains, so the
WITH d LIMIT 50caps the fan-out before collecting. Raise it deliberately, not by accident.
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"}'
The MX-attribution chain and the SPF/DNSSEC scorecard go deeper than this, and need an API key — sign in to run those. To skip the query-writing entirely, run the guided posture flows in the browser.
Going further
- Use Cases — recipes and guided workflows organized by security job.
- Graph Schema — every label, edge, and property, including the full SPF/DMARC/DKIM/DNSSEC 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 Splunk Use Cases. The whisper_spf_chain and whisper_cname_chain macros wrap the same Cypher patterns — see Investigation Macros.