# Posture Audits

> Audit SPF chains, DKIM signers, DMARC reporting, MX and nameserver posture across a domain portfolio with copy-paste Cypher recipes on WhisperGraph.

*Source: https://www.whisper.security/docs/recipes/dns-email*
*Published: 2026-05-04*
*Last updated: 2026-10-06*

---
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](/docs/whisper-graph/getting-started), the [Graph Schema](/docs/whisper-graph/schema), and the [Procedures](/docs/whisper-graph/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](/products/intelligence/use-cases/dns-email-security/nameserver-hijack-dns-consistency) — list nameservers and flag lame or inconsistent delegation.
> - [Indicator Enrichment](/products/intelligence/use-cases/dns-email-security/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](/docs/workflows#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, `DKIM_SIGNED_BY` (to a `VENDOR`) and `DMARC_REPORTS_TO` (to a `DMARC_RECIPIENT`) all point **domain → target**.

**Key concepts:** [SPF](/glossary/spf) · [DMARC](/glossary/dmarc) · [DNS](/glossary/dns) · [Passive DNS](/glossary/passive-dns) · [Typosquatting](/glossary/typosquatting) · [Origin-IP discovery](/glossary/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.

```cypher expect=rows>0 seed=cloudflare.com verified=2026-09-02
// 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`

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

### 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.

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// 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`

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

### 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.

```cypher expect=rows>0 seed=apple.com verified=2026-10-06
// 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`

```json
[
  {"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 ... LIMIT`s, 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](#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.

```cypher expect=rows>0 seed=cloudflare.com verified=2026-09-02
// 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`

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

### 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.

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// 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`

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

```cypher expect=rows>0 seed=sendgrid.net verified=2026-09-02
// 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"}
]
```

**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).

### 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.

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// 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`

```json
[
  {"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 over `SPF_INCLUDE` fans 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).

### 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 expect=rows>0 seed=google.com verified=2026-09-02
// 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`

```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"
  ]
}]
```

**Costs:** milliseconds; two single hops from an indexed anchor, the second optional.

> **Broad ranges are the risk.** A `/16` in 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](#mx-attribution-whose-network-is-your-mail-on) above pivots an address into its ASN owner.

**From here, →** [Where a domain sends DMARC reports](#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.

```cypher expect=static seed=apple.com verified=2026-09-03 reason="DMARC policy records change whenever a domain updates them, so this block shows a captured result rather than a live 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
```
**Returns:** `dmarc_rua`

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

**Costs:** milliseconds; one anchored hop.

> **Empty result:** no `DMARC_REPORTS_TO` edge 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. A `rua` at a known processor (like `rua.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).

### 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.

```cypher expect=rows>0 seed=github.com verified=2026-09-02
// 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`

```json
[{
  "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_signers` list 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_to` next 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).

### 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.

```cypher expect=rows>0 seed=stripe.com verified=2026-09-02
// 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`

```json
[{
  "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 > 0` confirms an SPF policy exists; a non-empty `dmarc_recipients` confirms aggregate reporting; `dkim_signers` names 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](#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.

```cypher expect=rows>0 seed=mail.google.com verified=2026-09-02
// 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`

```json
[
  {"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 `parent` from 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_OF` is 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).

### 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.

```cypher expect=rows>0,no-null-columns seed="VeriSign Global Registry Services" verified=2026-09-02
// 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`

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

### 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](/docs/whisper-graph/procedures/origins)), 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.

```cypher expect=static seed=bitwarden.com verified=2026-09-02 reason="whisper.origins weighs a wide candidate set and its result shifts as evidence accumulates, so this block shows a captured result rather than a live 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
```
**Returns:** `ip, confidence, methods`

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

> **`YIELD` the columns you need.** The procedure also returns network attribution (`asnName`) and a `kind`/`category` pair 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. `confidence` runs from `0.0` to `1.0`: a candidate corroborated by more than one method scores highest.

**From here, →** [Batch nameserver audit](#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.

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// 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`

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

### 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.

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// 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`

```json
[
  {"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_signers` list or a zero `dmarc_recipients` count 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 `UNWIND` list 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](#find-registered-lookalikes-then-check-who-their-mail-points-at).

---

## Lookalike & impersonation defense

> **Read `coverage` before `band`.** Only `known-clean` licenses the word "clean"; `no-data` means
> *unknown*, which is a different thing again; `malicious-evidenced` and `ambiguous` mean there is
> evidence, whatever the band says.
> Full contract: [Coverage — what we looked at](/docs/whisper-graph/procedures/coverage).

### 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 expect=rows>0 seed=paypal.com verified=2026-09-02
// 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:

```cypher expect=rows>0 seed=paypa1.com verified=2026-09-02
// 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
```

```json
[{"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.variants` returns what *exists*, not what's bad, and a lookalike parked at a brand-protection registrar (as above) is the brand defending itself. The `verdictLevel` / `verdictBlocking` fields and `CALL explain("paypa1.com")` give the reconciled, evidence-backed answer. See [explain()](/docs/whisper-graph/procedures/explain) for the full factor breakdown, and the [Lookalike Hunting](/docs/recipes/brand-protection) recipes for the brand-protection workflow.

**From here, →** [Sibling domains on a shared nameserver](#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.

```cypher expect=rows>0 seed=ns1.google.com verified=2026-09-02
// 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](#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:

```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"}'
```

For a key of your own, [sign in](https://console.whisper.security/sign-in?redirect_url=https%3A%2F%2Fwww.whisper.security%2Fdocs%2Frecipes%2Fdns-email) and pass it in the `X-API-Key` header. To skip the query-writing entirely, run the [guided posture flows](/products/intelligence/use-cases/dns-email-security) in the browser.

## Going further

- **[Workflows](/docs/workflows)** — recipes and guided workflows organized by security job.
- **[Graph Schema](/docs/whisper-graph/schema)** — every label, edge, and property, including the full SPF/DMARC/DKIM model.
- **[Procedures](/docs/whisper-graph/procedures)** — `explain`, `whisper.variants`, `whisper.history`, `whisper.origins`.
- **[Threat Feeds & Categories](/docs/whisper-graph/threat-feeds)** — the feeds and categories behind the reconciled verdict.
- **[AI & Agents](/docs/ai)** — 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 [Workflows](/docs/workflows). The `whisper_spf_chain` and `whisper_cname_chain` macros wrap the same Cypher patterns; see [Investigation Macros](/docs/integrations/splunk/reference#macros).
