Skip to contentSkip navigation

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)

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:

More guided flows live on the DNS & Email Security page.

Direction cheatsheet for this page. NAMESERVER_FOR and MAIL_FOR both point server → domain, so a domain's nameservers/MX are reached backwards: (domain)<-[:MAIL_FOR]-(mx). RESOLVES_TO is hostname → IP. CHILD_OF is 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.

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

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

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

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

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

json
[{
  "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. ROUTES and ANNOUNCED_BY are 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.

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

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

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

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

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

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

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

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

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

json
[
  {"dmarc_rua": "d@rua.agari.com"}
]

An empty result is a finding. No DMARC_REPORTS_TO edge means no aggregate reporting is observed for the zone — either DMARC isn't deployed, or rua is unset. Either way it's a row for your remediation list, not a passing grade. A rua at a known processor (like rua.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.

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

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

json
[{
  "domain": "stripe.com",
  "spf_mechanisms": 3,
  "dmarc_recipients": 0,
  "dnssec_algorithms": []
}]

How to read the three columns. spf_mechanisms > 0 confirms an SPF policy exists; dmarc_recipients > 0 confirms aggregate reporting; a non-empty dnssec_algorithms confirms 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.

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

json
[
  {"subdomain": "mail.google.com", "parent": "google.com", "grandparent": "com"}
]

Re-anchor to go deeper. For a longer chain, take the parent from 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.

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

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

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

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

YIELD the 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.

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

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

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

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

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.

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

cypher · runnablegraph.whisper.securitySign in to run
// 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.variants returns what exists, not what's bad. The verdictLevel / verdictBlocking fields and CALL 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.

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

bash
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.
  • Proceduresexplain, 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.security and 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.