Third-Party & Portfolio Posture
Cypher for the two jobs that read a vendor from the outside: cyber-insurance risk scoring and compliance evidence an auditor can verify.
Third-Party & Portfolio Posture Documentation
You have a vendor's domain and a questionnaire they filled in themselves. These recipes take you to the two artefacts built from the same passive data: the underwriting inputs behind a cyber-insurance risk score, and the registration, jurisdiction and sanctions evidence a compliance or audit file has to survive on. Nothing here touches the vendor's systems, and every claim resolves to a graph edge you can cite.
Everything runs against https://graph.whisper.security/api/query, read-only, anchored on an indexed name. Copy, paste, swap the anchor. First call and keys: Getting Started. Field model: Graph Schema.
Key concepts: WHOIS · RDAP · DNSSEC · DMARC · RPKI ROA · Reconciled verdict.
Some need an account. The jurisdiction, WHOIS-history and full-profile recipes cross more layers than the signed-out path runs, so sign in to run them — there is no card to enter. Pass the key in the
X-API-Keyheader.
Run it live: Digital Infrastructure Mapping and Supply-Chain Dependency Mapping run these pivots as guided investigations on your own vendor.
Four things that bite, once, on every page below. NAMESERVER_FOR and MAIL_FOR point server → domain, so those hops are written backwards. BELONGS_TO reaches the allocated PREFIX and ANNOUNCED_BY reaches the BGP-announced one; DELEGATED_TO, ROA_AUTHORIZES_PREFIX and PREFIX_IN_REGION all hang off the allocated prefix. Prefer verdictScore / verdictLevel / verdictBlocking over the older threatScore / threatLevel — both families are populated, so never mix them inside one rule. And every query anchors on a name and reaches FEED_SOURCE or CT_OBSERVATION through its edge, because scanning those labels does not finish.
Registration and ownership
What does the registration record actually say?
Registrar, nameservers, mail and SPF are four lookups you normally stitch together by hand. One anchored query with an OPTIONAL MATCH per field returns the whole surface — the raw material for a hygiene score and the evidence pack in one row.
// Infrastructure overview: registrar, nameservers, mail servers, SPF includes
MATCH (h:HOSTNAME {name: "stripe.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
OPTIONAL MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(h)
OPTIONAL MATCH (mx:HOSTNAME)-[:MAIL_FOR]->(h)
OPTIONAL MATCH (h)-[:SPF_INCLUDE]->(spf:HOSTNAME)
RETURN h.name AS domain,
collect(DISTINCT r.name) AS registrar,
collect(DISTINCT ns.name) AS nameservers,
collect(DISTINCT mx.name) AS mailservers,
count(DISTINCT spf) AS spf_includes
LIMIT 1
Returns: domain, registrar, nameservers, mailservers, spf_includes
[{
"domain": "stripe.com",
"registrar": ["iana:447"],
"nameservers": ["ns-423.awsdns-52.com", "ns-705.awsdns-24.net"],
"mailservers": ["aspmx.l.google.com"],
"spf_includes": 1
}]
Costs: one indexed anchor, four optional single hops, no traversal past it.
Every field takes
OPTIONAL MATCH: one mandatoryMATCHon a missing WHOIS field drops the whole row, which is how an evidence pack loses a vendor. Registrar ids use theiana:NNNNform, where NNNN is the IANA registrar id — resolve it at the IANA database. Add(h)-[:REGISTERED_BY]->(:ORGANIZATION)for the registrant and(h)-[:PREV_REGISTRAR]->(:REGISTRAR)to catch a transfer, which is a control change worth flagging by itself.Read
nameserverstwice: recognized cloud DNS (Route 53, Azure DNS, Google Cloud DNS) is a hygiene signal; a single provider with no secondary is a resilience finding.spf_includesabove zero means an email authorization policy is published at all. Concentration in the physical layer — facilities, exchanges, submarine cables — is a different traversal: Supply-Chain Dependency Mapping.
From here, → Who registered it, and what else do they own?, or When did the registrar or nameservers last change? when a transfer near a renewal needs dating.
Who registered it, and what else do they own?
Confirming a registrant normally means reading free-text WHOIS org fields and hoping they are spelled consistently. Registrant organizations are nodes, so you verify identity in one hop and fold in the entities already reconciled to the same owner.
// Registrant organization(s) for a domain, with reconciled aliases
MATCH (h:HOSTNAME {name: "stripe.com"})-[:REGISTERED_BY]->(org:ORGANIZATION)
OPTIONAL MATCH (org)-[:SAME_ORG_AS]->(alias:ORGANIZATION)
RETURN h.name AS domain,
collect(DISTINCT org.name) AS registrant_orgs,
collect(DISTINCT alias.name) AS reconciled_aliases
LIMIT 5
Returns: domain, registrant_orgs, reconciled_aliases
[{
"domain": "stripe.com",
"registrant_orgs": ["domain admin", "stripe"],
"reconciled_aliases": []
}]
Costs: one indexed anchor plus a single hop each way.
WHOIS carries several org entries — registrant, admin, technical — so
collect(DISTINCT …)folds them into one evidence line. Privacy redaction covers a large share of current WHOIS: a missing registrant is withheld, never none.RDAP is not the path. The graph holds 370,085
RDAP_ENTITYrecords and they carry zero edges of any type (measured 2026-08-09), so none is reachable from a domain or citable as provenance. Registration runs throughREGISTERED_BY→ORGANIZATION,HAS_EMAIL,HAS_PHONEandHAS_REGISTRAR.
From here, → When did the registrar or nameservers last change?.
When did the registrar or nameservers last change?
WHOIS shows you now. Proving when control moved — a transfer, a nameserver swap, the creation date of a domain that appeared last week — needs an archive you probably do not keep.
// Timestamped WHOIS snapshots for evidence collection
CALL whisper.history("google.com")
YIELD createDate, updateDate, registrar, registrant, nameServers
RETURN createDate, updateDate, registrar, registrant, nameServers
LIMIT 5
Returns: createDate, updateDate, registrar, registrant, nameServers
[
{"createDate": "1997-09-05", "updateDate": "2024-08-02", "registrar": "MarkMonitor, Inc.", "registrant": "Google LLC", "nameServers": "ns1.google.com|ns2.google.com|ns3.google.com|ns4.google.com"}
]
Costs: a procedure call, not a traversal. BGP history over a large network takes many seconds — keep the LIMIT on it.
whisper.history()requires an API key and does not run signed out. For a domain it returns WHOIS snapshots; for an IP, ASN or prefix, BGP routing history. Gaps are normal — record the snapshot's own timestamp as the evidence date, never "today". Signature.
From here, → Who hosts this vendor, and is that network clean?.
Hosting attribution and network reputation
Who hosts this vendor, and is that network clean?
"Who hosts them" is address → announced prefix → ASN → network name, and the prefix-to-ASN join is the step a DNS tool cannot make. Chaining it to the AS reputation rollup answers attribution and hygiene in one round-trip.
// Vendor domain -> hosting ASN -> AS reputation, in one traversal
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
-[:HAS_NAME]->(n:ASN_NAME)
RETURN DISTINCT n.name AS network,
a.name AS asn,
a.overallThreatLevel AS as_threat_level,
a.hasThreateningPrefixes AS as_has_bad_prefixes
LIMIT 5
Returns: network, asn, as_threat_level, as_has_bad_prefixes
Costs: four explicit single hops from an indexed anchor. The AS reputation is precomputed on the ASN node, so nothing scans the prefixes it routes.
This turns "they use AWS" into an attribution plus a hygiene grade in one cell. Large organizations sit across two to five ASNs — CDN, cloud, legacy data centre — so several providers is a resilience signal, one small hosting AS behind a material vendor is a follow-up question, and
hasThreateningPrefixes = trueon its own does not make a vendor compromised.When you already hold the ASN, anchor it directly (
MATCH (a:ASN {name: "AS13335"}); names are unique) for the same columns plusmaxThreatScoreandavgThreatScore. AddOPTIONAL MATCH (a)-[:HAS_COUNTRY]->(co:COUNTRY)for the operator's registered home jurisdiction, orexplain("AS13335")for the evidence-backed version.
From here, → Who actually runs this, behind the AS and the CDN? when the AS is not the operator.
Who actually runs this, behind the AS and the CDN?
An address belongs to an AS, but the AS is often not the operator: the netblock is delegated onward to a cloud or SaaS vendor. DELEGATED_TO maps the allocated prefix to the VENDOR that runs it.
// Who actually operates the netblock a vendor resolves into?
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)
OPTIONAL MATCH (p)-[:DELEGATED_TO]->(v:VENDOR)
RETURN DISTINCT p.name AS prefix, collect(DISTINCT v.displayName) AS operated_by
LIMIT 10
Returns: prefix, operated_by
Costs: anchored fan-out over resolving addresses, then two single hops each.
This is where a self-hosting claim meets ground truth: if the questionnaire says "on-premises, self-managed" and
operated_byreturns["Amazon AWS"], you have a concrete discrepancy to raise rather than a suspicion.
A CDN hides the origin as well, so run whisper.origins() before trusting any attribution for a CDN-fronted domain — the origin's AS reputation, not the CDN's, reflects the vendor's own posture.
CALL whisper.origins("cloudflare.com")
YIELD ip, confidence, methods, asnName
RETURN ip, confidence, methods, asnName
ORDER BY confidence DESC
LIMIT 10
Returns: ip, confidence, methods, asnName
Costs: a procedure call over precomputed passive observations, ordered by confidence.
methodsnames how each origin candidate was found —mx,spf, sibling hosts — and a candidate corroborated by several of them scores highest. Details: whisper.origins().
From here, → Which countries can this traffic land in?.
Jurisdiction and data residency
Which countries can this traffic land in?
"What countries does this vendor's infrastructure touch" needs DNS resolution, a GeoIP lookup per address and a dedup pass. One anchored traversal walks host → address → city → country and counts the exposure.
// Countries a domain's resolved IPs geolocate to
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH DISTINCT ip LIMIT 200
MATCH (ip)-[:LOCATED_IN]->(:CITY)-[:HAS_COUNTRY]->(co:COUNTRY)
RETURN co.name AS country, count(DISTINCT ip) AS ip_count
ORDER BY ip_count DESC
LIMIT 25
Returns: country, ip_count
[{"country": "US", "ip_count": 2}]
Costs: anchored fan-out staged with WITH DISTINCT, so the geo hops run over a bounded set.
This is the GeoIP location of resolved addresses, not the legal seat of the operator — two questions a residency review needs on the record separately, and citing one as the other is the mistake this recipe prevents. Anycast and large-CDN addresses serve many regions from one address and often carry no city edge at all. Read the list as where traffic can land, then corroborate with the ASN's own
HAS_COUNTRY.
From here, → Is it in the cloud region the contract names?.
Is it in the cloud region the contract names?
"Is this in eu-west-1, or did it drift to a US region" is not answerable from DNS at all, and it is the form of the question a contract actually uses.
// Cloud region(s) a domain's hosting prefixes sit in
MATCH (h:HOSTNAME {name: "netflix.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH DISTINCT ip LIMIT 50
MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)-[:PREFIX_IN_REGION]->(reg:CLOUD_REGION)
RETURN DISTINCT reg.name AS cloud_region, count(DISTINCT ip) AS ip_count
ORDER BY ip_count DESC
LIMIT 25
Returns: cloud_region, ip_count
Costs: staged fan-out plus two single hops. Coverage is seed-stage: 3,288 PREFIX_IN_REGION edges against 2.5M prefixes, measured 2026-08-09 — well under a tenth of a percent of routed space, weighted toward the large public clouds.
Empty result: region names are provider-prefixed (
aws:eu-west-1). Zero rows means Whisper has not mapped that prefix to a tracked region. It never means the host is not in a cloud, and it is not evidence of on-premises hosting for an audit. If residency is load-bearing, confirm against the provider's published ranges and record that as the source. Zero rows is never a verdict.
From here, → What is the reconciled verdict, with its working?.
Threat and sanctions screening
Read
coveragebeforeband. Onlyknown-cleanlicenses the word "clean";no-datameans unknown, which is a different thing again;malicious-evidencedandambiguousmean there is evidence, whatever the band says.whisper.explaindoes not returncoverageat all. Full contract: Coverage — what we looked at.
What is the reconciled verdict, with its working?
Feeds disagree, and "listed by two of them" says nothing without knowing which two and how they are weighted. explain() reconciles every feed into one inspectable verdict — score, normalized level, and the evidence chain you paste into the file.
CALL explain("cloudflare.com")
YIELD indicator, type, found, score, level, explanation
RETURN indicator, type, found, score, level, explanation
Returns: indicator, type, found, score, level, explanation
[{
"indicator": "cloudflare.com",
"type": "domain",
"found": true,
"score": 0.0,
"level": "NONE",
"explanation": "cloudflare.com is listed in 0 threat feed(s). Score 0.0 (No known risk)."
}]
Costs: an anchored procedure call, no traversal. It behaves the same on an IP, ASN or CIDR.
For an automated underwriting rule key off
level(NONE/INFO/LOW/MEDIUM/HIGH/CRITICAL) rather than the rawscore: the bands are calibrated for human-readable risk language. Name the columns you want inYIELD—explain()also returnsfactors[]with the scoring arithmetic andsources[]naming each feed, which is what a file needs to show its working. Signature.
From here, → Which flags can a rule engine branch on?.
Which flags can a rule engine branch on?
"Should we block this, and why" usually means buying a second product. Every listed node already carries a reconciled, blocking-aware verdict plus typed booleans a pipeline can branch on.
// Reconciled verdict + blocking flag for a vendor's resolving IPs
MATCH (h:HOSTNAME {name: "github.com"})-[:RESOLVES_TO]->(ip:IPV4)
RETURN ip.name AS ip,
ip.verdictScore AS score,
ip.verdictLevel AS level,
ip.verdictBlocking AS should_block,
ip.isC2, ip.isMalware, ip.isPhishing, ip.isBotnet
LIMIT 10
Returns: ip, score, level, should_block, isC2, isMalware, isPhishing, isBotnet
Costs: one anchored hop, then a property read per address. The flags cost nothing extra.
verdictBlocking = trueon a vendor's production address is a material finding, and the flags say what kind of exposure it is without a second lookup. Trust feeds live in the same model, so a benign popular domain reads as known-good rather than merely absent from block lists — Threat Feeds & Categories.
From here, → Which feeds and categories flag this address?.
Which feeds and categories flag this address?
Sanctions screening usually stops at company names, and a name match cannot see infrastructure. Every indicator carries a LISTED_IN edge to each feed that flagged it, with the feed's category attached — the difference between a score and a citation.
// Which feeds and categories flag an IP, with the reconciled verdict
MATCH (ip:IPV4 {name: "185.220.101.1"})
OPTIONAL MATCH (ip)-[:LISTED_IN]->(f:FEED_SOURCE)-[:BELONGS_TO]->(cat:CATEGORY)
RETURN ip.threatLevel AS verdict,
ip.isThreat AS flagged,
collect(DISTINCT f.name) AS feeds,
collect(DISTINCT cat.name) AS categories
LIMIT 1
Returns: verdict, flagged, feeds, categories
Costs: an anchored property read plus a bounded two-hop expansion into the catalogue.
threatScore,threatLevelandisThreatsit on the node, reconciled across every feed that lists it; the feed and category lists are the citation. The sanctions-relevant categories areOFAC SDN SanctionsandState Actor & Sanctions, out of 76 feeds across 31 categories.
From here, → Which domains on the watchlist resolve to flagged infrastructure?.
Which domains on the watchlist resolve to flagged infrastructure?
Screening a vendor list with flat tools is one threat lookup per name, then filtering. UNWIND the watchlist and let the node flags filter it in a single round-trip, so only the entries that matter come back.
// Screen a domain watchlist for feed-flagged hosting
UNWIND ["paypal.com", "stripe.com", "webmail.inini.casa"] AS domain
MATCH (h:HOSTNAME {name: domain})-[:RESOLVES_TO]->(ip:IPV4)
WHERE ip.isThreat = true
RETURN domain,
ip.name AS flagged_ip,
ip.threatLevel AS verdict,
ip.isTor AS tor_exit,
ip.isAnonymizer AS anonymizer
LIMIT 50
Returns: domain, flagged_ip, verdict, tor_exit, anonymizer
[{
"domain": "webmail.inini.casa",
"flagged_ip": "78.128.76.165",
"verdict": "CRITICAL",
"tor_exit": false,
"anonymizer": false
}]
Costs: every element is still an anchored name lookup. Keep the list short enough to stay in one round-trip.
Only the flagged entry returns:
paypal.comandstripe.comresolve fine and drop out because none of their addresses carryisThreat. A clean result set is a clean screen for that run — record the query time as the point-in-time stamp. One caveat that costs people findings: large CDNs host many tenants, so a listing somewhere inside their address space says little about your vendor, and anchoring on(h)-[:RESOLVES_TO]->(ip)is what keeps the question about this hostname.
From here, → Is this address a Tor exit or an anonymizer? on anything flagged.
Is this address a Tor exit or an anonymizer?
An address rotates, but its role as a Tor exit persists, and flat reputation lookups miss that identity layer.
// Is an IP a Tor exit, and what's the reconciled anonymizer posture
MATCH (ip:IPV4 {name: "185.220.101.1"})
OPTIONAL MATCH (ip)-[:OPERATES_EXIT_NODE]->(t:TOR_RELAY)
RETURN ip.name AS ip,
ip.isTor AS is_tor,
ip.isAnonymizer AS is_anonymizer,
collect(t.name) AS tor_relay_fingerprints
LIMIT 1
Returns: ip, is_tor, is_anonymizer, tor_relay_fingerprints
Costs: an anchored property read plus one optional hop to the relay.
isToris a reconciled flag; the relay fingerprint behind it is the durable identity, survives an address change, and is what makes the finding citable. Anonymizing egress inside a vendor's production range is a question for the review, not by itself a finding.
From here, → Is a clean answer clean, or just empty?.
Is a clean answer clean, or just empty?
"No findings" reads as clean to everyone who opens your file, and absence of data is not evidence of safety. whisper.assess() returns the verdict with a coverage qualifier attached.
// Verdict plus how much the graph actually knows about the host
CALL whisper.assess(["github.com"])
YIELD host, label, band, coverage, evidence
RETURN host, label, band, coverage, evidence
LIMIT 5
Returns: host, label, band, coverage, evidence
[{
"host": "github.com",
"label": "clean",
"band": "INFO",
"coverage": "known-clean",
"evidence": ["coverage:known-clean", "band:INFO", "host-class:multi_tenant_user_content", "feed-source:listed", "feed-source-count:3"]
}]
Costs: a procedure call over a list of hosts, no traversal.
Identity is a separate question from verdict:
whisper.identify()confirms whose infrastructure a host is before you score it. Both are on the Procedures page.
From here, → Are the routes carrying this vendor authorized?.
Routing integrity
Are the routes carrying this vendor authorized?
RPKI validation normally means cross-referencing announced origins against ROAs held in a separate system. The ROA layer is in the same graph.
// Does the prefix carrying a vendor's IP have an RPKI ROA?
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)
OPTIONAL MATCH (roa:ROA)-[:ROA_AUTHORIZES_PREFIX]->(p)
RETURN DISTINCT p.name AS prefix,
(roa IS NOT NULL) AS roa_protected,
collect(DISTINCT roa.asn) AS authorized_origins
LIMIT 10
Returns: prefix, roa_protected, authorized_origins
Costs: anchored fan-out, then two single hops and one optional ROA hop per prefix.
A vendor whose prefixes carry no ROA (
roa_protected = false) is more exposed to route hijacking — routing hygiene flat DNS tooling cannot produce.Empty result: no rows means the address did not reach an allocated prefix, not that routing is unprotected.
roa_protected = falseon a returned row is the finding; no row at all is a gap in the lookup. Zero rows is never a verdict.
From here, → Is the prefix in a MOAS conflict?.
Is the prefix in a MOAS conflict?
A prefix announced by two origin ASNs is the leading early signal of a BGP hijack, and it lives in routing data most risk tools never load.
// Is a vendor's prefix currently in a MOAS conflict?
MATCH (h:HOSTNAME {name: "tiny-vps.com"})-[:RESOLVES_TO]->(ip:IPV4)
-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
WHERE ap.isMoas = true
MATCH (ap)-[:CONFLICTS_WITH]->(other:ASN)
RETURN ap.name AS prefix, collect(DISTINCT other.name) AS conflicting_asns
LIMIT 10
Returns: prefix, conflicting_asns
Costs: fan-out filtered on a boolean before the conflict hop, so the expansion only runs on prefixes already flagged.
Empty result: an empty result is the answer you want, and the one you will usually get. The graph holds 15,558
CONFLICTS_WITHedges, so the overwhelming majority of vendors are not in conflict — the seed above is a small VPS host that is. Read a blank result as this vendor's prefixes have a single origin today, not as we could not check. Zero rows is never a verdict. Pair it withwhisper.history()on the prefix, which shows how the origin moved and catches a conflict that has already resolved.
From here, → How large is the external surface?.
Footprint and posture
How large is the external surface?
An organization's real surface is its subdomains, and many never appear in a DNS scan. Size the estate from the domain hierarchy, then add Certificate Transparency — names that only ever appeared inside a TLS certificate.
// Size the subdomain estate from the anchored parent
MATCH (sub:HOSTNAME)-[:CHILD_OF]->(:HOSTNAME {name: "github.com"})
RETURN count(sub) AS direct_children
LIMIT 1
Returns: direct_children
// Certificate-Transparency observations for a domain
MATCH (h:HOSTNAME {name: "partillebryggeri.se"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.fqdn AS observed_name, ct.certCount AS certs, ct.wildcard AS wildcard
ORDER BY ct.lastSeen DESC
LIMIT 20
Returns: observed_name, certs, wildcard
Costs: both anchored on the parent hostname and bounded. CT coverage is seed-stage — 29,453 observations against 2.7B hostnames as of 2026-08-09, under one hundredth of one percent. github.com has none. paypal.com has none.
Empty result: zero rows on the second query means Whisper holds no CT observation for that host. It never means the host has a clean certificate history. If certificate history is load-bearing, query a CT log directly — crt.sh or the Google CT API — and come back with the names you find. Zero rows is never a verdict.
Treat every count as a floor rather than a census — passive data reflects what was observed. A large footprint is not itself bad, but it sizes the surface, and a stray
vpn.,rdp.orold-host that still resolves is exactly what a questionnaire misses.
From here, → Which zones in the portfolio are unsigned, and who may send as them?.
Which zones in the portfolio are unsigned, and who may send as them?
Checking whether a zone is signed, and with which algorithm, is a dig +dnssec per domain and a parse of the answer. A signed zone links to DNSSEC_ALGORITHM nodes, so UNWIND reports the whole list at once.
// Signed-vs-unsigned posture across a domain portfolio
UNWIND ["google.com", "cloudflare.com", "paypal.com", "stripe.com"] AS domain
MATCH (h:HOSTNAME {name: domain})
OPTIONAL MATCH (h)-->(alg:DNSSEC_ALGORITHM)
RETURN domain,
count(alg) > 0 AS dnssec_enabled,
collect(DISTINCT alg.name) AS algorithms
ORDER BY dnssec_enabled
LIMIT 100
Returns: domain, dnssec_enabled, algorithms
Costs: one anchored lookup per element plus an optional hop each. Keep the list short enough to stay in one round-trip.
An empty
algorithmslist means the zone is not signed — usually the finding you are documenting, not an error. There are 8 tracked signing algorithms and any one of them is your "DNSSEC enabled" evidence.ORDER BY dnssec_enabledfloats the unsigned zones to the top; drop theUNWINDline and anchor a singlenamefor one domain.
Email posture is the other half of the same audit: confirming DMARC and finding where its aggregate reports go is two more DNS lookups and a TXT parse, and here both the report destinations and the SPF authorization tree are edges you walk.
// DMARC report destinations + first-level SPF includes for a domain
MATCH (h:HOSTNAME {name: "apple.com"})
OPTIONAL MATCH (h)-[:DMARC_REPORTS_TO]->(d:DMARC_RECIPIENT)
OPTIONAL MATCH (h)-[:SPF_INCLUDE]->(inc:HOSTNAME)
RETURN h.name AS domain,
collect(DISTINCT d.name) AS dmarc_report_destinations,
collect(DISTINCT inc.name) AS spf_includes
LIMIT 1
Returns: domain, dmarc_report_destinations, spf_includes
Costs: one indexed anchor and two optional single hops.
Empty result: DMARC coverage is still early-stage, so an empty
dmarc_report_destinationslist means no reporting address is recorded yet. Verify with a direct TXT lookup before flagging it as a gap in the vendor's posture. Zero rows is never a verdict.
The six SPF edge types —
SPF_INCLUDE,SPF_IP,SPF_A,SPF_MX,SPF_EXISTS,SPF_REDIRECT— walk the full authorization tree when an auditor asks who may send as this domain. Full workup: Posture Audits.
From here, → What does the registration record actually say? to close the loop and assemble the file.
Run it from a terminal
The endpoint answers a plain curl, which is enough to sanity-check a resolution before wiring anything up. Send an explicit User-Agent; the production WAF rejects some default programmatic agents.
curl -s -A "whisper-client/1.0" https://graph.whisper.security/api/query \
-H "Content-Type: application/json" \
-d '{"query":"MATCH (h:HOSTNAME {name:\"stripe.com\"})-[:RESOLVES_TO]->(ip:IPV4) RETURN h.name, ip.name LIMIT 5"}'
Where to next
- Wire it into an agent. Point an MCP client at
https://mcp.whisper.securityand a due-diligence assistant runs these traversals mid-conversation, citing graph edges instead of guessing — AI & Agents, MCP setup. - Score third-party risk in your SIEM. The
whisper_explainmacro puts the same reconciled verdict beside your own events — Splunk. - Reusable pivots. Cross-Layer Patterns catalogs the building blocks; Procedures covers
explain(),whisper.history(),whisper.origins()and the rest. - Know your feeds. Threat Feeds & Categories names every feed and category behind a screening decision.