Compliance Evidence

Registrar and jurisdiction checks, sanctions screening, and portfolio-wide posture audits that produce evidence an auditor can verify.

Updated July 2026

Compliance Evidence Documentation

You run vendor due diligence, jurisdiction and data-residency reviews, and sanctions screening, and every finding has to survive an auditor. WhisperGraph gives compliance and risk teams sourced, point-in-time evidence: registrar and jurisdiction lookups that resolve in one hop, timestamped WHOIS history you can attach to a ticket, DNSSEC and email-posture audits across a whole portfolio, and threat-feed screening that names the exact source behind every flag. Every recipe below is copy-paste against https://graph.whisper.security/api/query and anchors on a name you control.

New to the graph? Start with Getting Started; the field model lives in the Graph Schema.

Run it live: Digital Infrastructure Mapping and Supply-Chain Dependency Mapping each open with a live result you can re-run on your own vendor. The Infrastructure & Supply Chain landing lists every runnable workflow for this job.

Key concepts: WHOIS · RDAP · DNSSEC · DMARC · Reconciled verdict.

Hop budget. Anonymous queries are capped at 2 traversal hops; a free API key raises that to 3, and paid plans go deeper. The jurisdiction and full-profile recipes below cross 3 hops, so get a free key if a run returns a query-depth-exceeded error.

Quick checks

Registrar verification

A flat WHOIS lookup gives you a registrar string you then normalize and match against your approved-registrar list by hand. In the graph the registrar is a node, reached in one hop, so you can verify it and (in the next recipe) pivot to every other domain that shares it.

// Current registrar for a domain
MATCH (h:HOSTNAME {name: "stripe.com"})-[:HAS_REGISTRAR]->(r:REGISTRAR)
RETURN h.name AS domain, r.name AS registrar
LIMIT 5
[{"domain": "stripe.com", "registrar": "iana:447"}]

Registrar identifiers use the iana:NNNN format, where the number is the IANA registrar ID. Resolve any ID at the IANA registrar database. To catch a registrar transfer, a control-change event worth flagging, pull the prior registrar too with OPTIONAL MATCH (h)-[:PREV_REGISTRAR]->(prev:REGISTRAR).

Registrant organization verification

Confirming who registered a vendor's domain normally means reading free-text WHOIS org fields and hoping they are consistent. Registrant organizations are first-class nodes here: verify identity, then use SAME_ORG_AS to fold in entities the graph has 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
[{
  "domain": "stripe.com",
  "registrant_orgs": ["domain admin", "stripe"],
  "reconciled_aliases": []
}]

WHOIS records often carry several organization entries: registrant, admin, and technical contacts may each have a distinct org field. collect(DISTINCT ...) deduplicates them into one evidence line. Privacy services redact a large share of current WHOIS, so treat a missing registrant as "withheld", not "none", and fall back to the WHOIS-history recipe below.

Jurisdiction & data residency

Domain jurisdiction exposure

Answering "what countries does this vendor's infrastructure touch?" with flat tools needs DNS resolution, a GeoIP lookup per IP, then dedup. One anchored traversal walks host to IP to city to 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
[{"country": "US", "ip_count": 2}]

This reflects GeoIP location of the resolved addresses, not the legal seat of the operator. Anycast and large-CDN IPs (Cloudflare, Google) may serve many regions from one address, and often carry no city edge at all. Treat the country list as "where traffic can land", and corroborate with the ASN home jurisdiction below before drawing a data-residency conclusion.

ASN home jurisdiction (network operator)

The operator's jurisdiction and the GeoIP of its addresses are different questions, and most tools only answer the second. HAS_COUNTRY also hangs off the ASN node, so you get the network's registered home country directly.

// Network operator and its registered home jurisdiction for a domain's IPs
MATCH (h:HOSTNAME {name: "stripe.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH DISTINCT ip LIMIT 50
MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (a)-[:HAS_COUNTRY]->(co:COUNTRY)
RETURN DISTINCT a.name AS asn, n.name AS network, co.name AS operator_country
LIMIT 25

Two jurisdictions, two questions: the previous recipe answers "where do packets land", this one answers "who legally operates the network and from which country". Data-residency reviews usually need both on the record.

Cloud-region residency

"Is this hosted in eu-west-1 or did it drift to a US region?" is not answerable from DNS at all. PREFIX_IN_REGION maps the announced prefix to a named cloud region.

// Cloud region(s) a domain's hosting prefixes sit in
MATCH (h:HOSTNAME {name: "github.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

Region names are provider-prefixed (aws:eu-west-1). Empty results mean the prefix is not mapped to a tracked cloud region, which is common for self-hosted or carrier networks, not an error.

Sanctions & threat-feed screening

Screen a vendor IP against sanctioned-infrastructure feeds

Sanctions screening usually stops at company names; the infrastructure layer is invisible to a name-match. In the graph, indicators carry a reconciled threat verdict on the node and a LISTED_IN edge to every feed that flagged them, with the feed's category attached.

// 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

threatScore, threatLevel, and isThreat live on the node, reconciled across every feed that lists it. The feed and category lists are your citation: a finding backed by which feed flagged it, not an opaque score. The sanctions-relevant categories are OFAC SDN Sanctions and State Actor & Sanctions; the graph carries 43 feeds across 25 categories, cataloged in Threat Feeds & Categories.

Screen a domain watchlist for flagged infrastructure

Screening a list of vendor domains with flat tools means one threat lookup each, then filtering. UNWIND your watchlist and let the reconciled node flags do the filtering in a single round-trip.

// Screen a domain watchlist for feed-flagged hosting
UNWIND ["paypal.com", "stripe.com", "example-vendor.com"] 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

Each domain in the list is still an anchored lookup, so the batch stays fast; keep watchlists to 50–100 domains per query. A clean result set is a clean screen for that run, so record the query time as the point-in-time stamp. For a scored, source-cited single-indicator verdict you can paste into a case file, use CALL explain("185.220.101.1") — see explain() — Threat Verdicts.

Tor / anonymizer exposure check

An IP can rotate, but its role as a Tor exit persists, and flat reputation lookups miss that identity layer. OPERATES_EXIT_NODE ties the IP to a stable Tor-relay fingerprint.

// 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

Point-in-time evidence

WHOIS history for an audit trail

WHOIS shows you now; proving when a registrar changed or when a domain was first created needs an archive you probably do not keep. whisper.history() returns timestamped snapshots you can attach as evidence.

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

whisper.history() requires an API key; it is not available on the anonymous tier. For a domain it returns WHOIS registration snapshots; for an IP, ASN, or prefix it returns BGP routing history. Gaps are normal, so record the snapshot's own timestamp, not "today", as your evidence date. BGP history over a large network can take many seconds; keep the LIMIT on it. Full detail: whisper.history() — Point-in-Time.

RDAP entity provenance

RDAP responses are nested JSON you parse per query. In the graph, WHOIS and RDAP registration entities are reconciled into ORGANIZATION nodes you reach straight from the domain, giving you a structured provenance record; the graph also holds ~355K RDAP_ENTITY deep-WHOIS records alongside them.

// Registration entities behind a domain
MATCH (h:HOSTNAME {name: "stripe.com"})-[:REGISTERED_BY]->(org:ORGANIZATION)
RETURN h.name AS domain, collect(DISTINCT org.name) AS registration_entities
LIMIT 5

Pair this with the WHOIS-history recipe above to show both the current registration entity and when it last changed.

Full registration & DNS profile (one-shot evidence pack)

Assembling a vendor's registrar, registrant, nameservers, and mail servers is four separate lookups stitched together. One query with OPTIONAL MATCH per field returns a complete, auditable profile, null where a field is genuinely absent.

// Complete registration + DNS profile for compliance documentation
MATCH (h:HOSTNAME {name: "microsoft.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
OPTIONAL MATCH (h)-[:REGISTERED_BY]->(org:ORGANIZATION)
OPTIONAL MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(h)
OPTIONAL MATCH (mx:HOSTNAME)-[:MAIL_FOR]->(h)
RETURN h.name AS domain,
       collect(DISTINCT r.name) AS registrar,
       collect(DISTINCT org.name) AS registrant_org,
       collect(DISTINCT ns.name) AS nameservers,
       collect(DISTINCT mx.name) AS mail_servers
LIMIT 1
[{
  "domain": "microsoft.com",
  "registrar": ["iana:292"],
  "registrant_org": ["domain administrator", "microsoft corporation"],
  "nameservers": ["ns1-39.azure-dns.com", "ns2-39.azure-dns.net", "ns3-39.azure-dns.org", "ns4-39.azure-dns.info"],
  "mail_servers": ["microsoft-com.mail.protection.outlook.com"]
}]

Note the direction landmine: NAMESERVER_FOR and MAIL_FOR point server → domain, so the nameserver and mail-server hops are reversed ((ns)-[:NAMESERVER_FOR]->(h)). Use OPTIONAL MATCH for every field; a mandatory MATCH on a missing WHOIS field would drop the whole row.

DNSSEC & email posture audits

DNSSEC posture for a single domain

Checking whether a zone is signed, and with which algorithm, normally means a dig +dnssec per domain and parsing the answer. In the graph, a signed zone links to one or more DNSSEC_ALGORITHM nodes.

// DNSSEC signing algorithm(s) for a domain
MATCH (h:HOSTNAME {name: "cloudflare.com"})
OPTIONAL MATCH (h)-->(alg:DNSSEC_ALGORITHM)
RETURN h.name AS domain, collect(DISTINCT alg.name) AS dnssec_algorithms
LIMIT 1

An empty dnssec_algorithms list means the zone is not signed, which is frequently the finding you are documenting. There are 8 tracked signing algorithms; the presence of any one is your "DNSSEC enabled" evidence.

Batch DNSSEC audit across a portfolio

Running the signed-vs-unsigned check across dozens of domains is a scripted loop with flat tools. UNWIND the portfolio and report posture for each in one query.

// 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

Keep UNWIND lists to 50–100 domains per query. ORDER BY dnssec_enabled floats the unsigned (false) zones to the top of your report.

DMARC reporting & SPF authorization audit

Confirming a domain has DMARC and knowing where its aggregate reports go is two more DNS lookups and a TXT-record parse. DMARC recipients and the SPF authorization tree are edges you walk directly.

// 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

DMARC coverage in the graph is still early-stage: an empty dmarc_report_destinations list means no reporting address is recorded yet, so verify with a direct TXT lookup before flagging it as a gap. The six SPF edge types (SPF_INCLUDE, SPF_IP, SPF_A, SPF_MX, SPF_EXISTS, SPF_REDIRECT) let you walk the full authorization tree when an auditor asks "who is allowed to send as this domain?" For the full email-posture workup, see Posture Audits.

Where to next

  • Extend to portfolio risk. Vendor & Portfolio Risk builds the underwriting view: shared-provider exposure and single-provider blast radius across a vendor list.
  • Build the screening into an agent. The AI & Agents section shows how to connect any MCP client so your assistant runs these screens mid-conversation, each finding citing a graph edge.
  • More patterns. Cross-Layer Patterns catalogs the reusable pivots these recipes are built from; Procedures covers explain(), whisper.history(), and the rest of the procedure set.
  • Know your feeds. Threat Feeds & Categories lists all 43 feeds and 25 categories so you can cite the exact source behind a screening decision.