Internet Measurement

Bulk, aggregate Cypher recipes for internet measurement: topology, RPKI coverage, the web link graph, physical infrastructure, and cross-layer studies.

Updated July 2026

Internet Measurement Documentation

You study the internet itself — topology, deployment trends, ecosystem structure — not one incident at a time but in aggregate, across billions of edges. The hard part isn't the analysis; it's getting clean, joined, planet-scale data to analyze. WhisperGraph is the joined data: DNS, the web link graph, BGP peering, RPKI, and the physical internet, all in one Cypher surface. The recipes below are bulk and aggregate queries, written with the bounding that keeps them fast on a ~7.4B-node / ~39B-edge graph.

A few rules that keep research queries honest at this scale:

  • Anchor or aggregate, never bare-scan. A query that touches the whole LINKS_TO edge set (the largest in the graph) without an anchored start will time out. Anchor on a {name:"..."} node, or aggregate behind a CALL db.* histogram.
  • Bound high-fan-out hops with WITH ... LIMIT before you expand again.
  • Mind your tier. Anonymous = 2 hops, free key = 3. Run CALL whisper.quota() to see yours. The deeper topology recipes need a free key.
  • Reach FEED_SOURCE / CATEGORY through an edge (LISTED_IN / BELONGS_TO), not a bare label scan — they're virtual labels synthesized at query time.
  • Expand physical edges as explicit single hops joined with WITH, not variable-length [*1..N] patterns — synthesized edges don't expand inside them, and unbounded walks time out.

See Getting Started for keys, the Graph Schema for the full label/edge model, and the Procedures reference for the CALL surface.

Run it live. Several of these measurement jobs have a guided, browser-runnable version that opens with a result on your own indicator:

  • Digital Infrastructure Mapping — one indicator mapped to its owner and full footprint across every layer.
  • Supply-Chain Dependency Mapping — every external provider a domain depends on, grouped by function, with dependency chains and single-vendor (SPOF) signals across facilities, cable landings and subsea cables.
  • Investigate an Indicator — verdict, hosting, routing, and the shared infrastructure around any domain, IP, ASN, or prefix.

More live flows are on the Research & OSINT use cases page.


Schema exploration

What's actually in the graph

Before you write a traversal, confirm the label and edge exist. The most common cause of a silent empty result set is anchoring on a label that doesn't exist (there is no DOMAIN or FQDN label — every name is a HOSTNAME).

The db.* procedures return pre-computed histograms — O(1) lookups that are instant even on billion-scale data, and they don't count against quota.

// Every node label with its live count
CALL db.labels()

Sample output (first 5 of 39 labels):

[
  {"label": "HOSTNAME", "count": 2631997144},
  {"label": "IPV4", "count": 618914961},
  {"label": "EMAIL", "count": 237065663},
  {"label": "ORGANIZATION", "count": 119189847},
  {"label": "PHONE", "count": 60194142}
]
// Every edge type with its live count
CALL db.relationshipTypes()

Sample output (first 5 of 44 edge types):

[
  {"type": "LINKS_TO", "count": 10851011448},
  {"type": "NAMESERVER_FOR", "count": 8881831888},
  {"type": "RESOLVES_TO", "count": 2919321504},
  {"type": "CHILD_OF", "count": 2338085185},
  {"type": "REGISTERED_BY", "count": 916255242}
]
// Every property name in the graph
CALL db.propertyKeys() YIELD propertyKey RETURN propertyKey ORDER BY propertyKey LIMIT 200

Why this matters: the edge histogram is a research dataset. LINKS_TO at 10.85B, NAMESERVER_FOR at 8.88B, RESOLVES_TO at 2.92B — the shape of the global internet, one CALL away. Use these counts to plan which traversals are cheap (anchored) and which need aggregation.

Confirm a property before you filter on it

Filtering on a property that doesn't exist returns empty, not an error. A quick keys() read (or db.propertyKeys()) saves you from WHERE h.fqdn = ... when the property is name.

// What properties does a threat-listed IP carry?
MATCH (ip:IPV4 {name: "185.220.101.1"})
RETURN keys(ip) AS properties
LIMIT 1

The threat feed catalog

Reach feeds through an edge rather than scanning the virtual FEED_SOURCE label. Anchor on a known-noisy IP, walk its LISTED_IN edges, then group up to categories.

// Which feeds and categories cover a known Tor exit?
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:LISTED_IN]->(f:FEED_SOURCE)
MATCH (f)-[:BELONGS_TO]->(c:CATEGORY)
RETURN c.name AS category, collect(f.name) AS feeds
ORDER BY category
LIMIT 25

The graph indexes 43 feeds across 25 categories with ~11.9M LISTED_IN edges. The catalog spans block lists (Spamhaus DROP/EDROP, Feodo Tracker, URLhaus, ThreatFox, FireHOL levels) and trust lists (Tranco Top 1M, Cloudflare Radar Top 1M) — so the same query surface answers "known-bad?" and "known-good?". The full list is in Threat Feeds & Categories.

The DNSSEC algorithm reference

A tiny reference label (8 rows) worth knowing — useful when you're tabulating signing-algorithm adoption.

// All DNSSEC signing algorithms recognized by the graph
MATCH (algo:DNSSEC_ALGORITHM) RETURN collect(algo.name) AS algorithms LIMIT 1

Sample output:

[{"algorithms": ["ECDSAP256SHA256", "ECDSAP384SHA384", "ED25519", "ED448", "RSASHA1", "RSASHA1-NSEC3-SHA1", "RSASHA256", "RSASHA512"]}]

Internet topology

BGP peering-degree, network by network

Hard with flat tools: peering data lives in PeeringDB and route-collector dumps you have to download, parse, and join yourself. The graph: BGP_NEIGHBOR is the canonical ASN↔ASN adjacency edge, already materialized. Anchor on a set of ASNs and count peers in one round-trip.

// Peering degree for a sample of well-known networks
UNWIND ["AS13335", "AS3356", "AS15169", "AS2914"] AS asn_name
MATCH (a:ASN {name: asn_name})-[:BGP_NEIGHBOR]->(peer:ASN)
RETURN asn_name, count(peer) AS peer_count
ORDER BY peer_count DESC

Reading it: transit-heavy Tier-1 carriers (AS3356 Lumen, AS2914 NTT) sit at the top of the degree distribution; content networks (AS15169 Google) peer selectively. The degree gap is the structural difference between transit and content ASNs — visible in one query. BGP_NEIGHBOR is canonical; PEERS_WITH is an accepted alias but use BGP_NEIGHBOR.

Rank the densest networks by prefix count

For a top-of-distribution view without enumerating every ASN, use the helper procedure. It reads a precomputed ranking, so it's instant where the equivalent per-ASN sweep would be expensive.

// The ASNs announcing the most prefixes
CALL whisper.topAsnsByPrefixCount(15)
YIELD asn, prefixCount
RETURN asn, prefixCount
LIMIT 15

To study a single network's footprint, anchor and bound the fan-out:

// How many prefixes does Cloudflare announce?
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(ap:ANNOUNCED_PREFIX)
RETURN count(ap) AS announced_prefixes
LIMIT 1

Second-degree peering reach (free key)

The peering graph's structure shows up in the two-hop neighborhood: how many distinct networks are within two BGP hops. Bound the first hop hard before expanding — a Tier-1's neighbor set is in the thousands.

// Distinct networks reachable within 2 BGP hops of Cloudflare
MATCH (a:ASN {name: "AS13335"})-[:BGP_NEIGHBOR]->(n1:ASN)
WITH DISTINCT n1 LIMIT 500
MATCH (n1)-[:BGP_NEIGHBOR]->(n2:ASN)
RETURN count(DISTINCT n2) AS two_hop_reach
LIMIT 1

The WITH ... LIMIT 500 is load-bearing. Without it the second hop fans out across every neighbor's full peer set and blows past your hop/time budget. Cap the frontier, then expand — two explicit hops, no variable-length pattern.

ASN home jurisdiction distribution

HAS_COUNTRY runs from ASN straight to COUNTRY — no city hop needed for the AS's registered jurisdiction. Aggregate across a sample to study where networks are domiciled.

// Home country for a sample of networks
UNWIND ["AS13335", "AS15169", "AS3356", "AS2914", "AS4837"] AS asn_name
MATCH (a:ASN {name: asn_name})-[:HAS_COUNTRY]->(c:COUNTRY)
RETURN c.name AS country, count(*) AS networks
ORDER BY networks DESC

The open web's hyperlink graph (Common Crawl) lives in the same query surface as DNS, WHOIS, and BGP — which means you can join link structure against routing or threat data without exporting a single CSV. LINKS_TO is the largest edge set in the graph (~10.85B edges). The one rule: always anchor. An unanchored LINKS_TO query touches all of it and times out.

// How many distinct hosts does github.com link out to?
MATCH (h:HOSTNAME {name: "github.com"})-[:LINKS_TO]->(target:HOSTNAME)
RETURN count(DISTINCT target) AS outbound_hosts
LIMIT 1

Reverse the arrow to count who links in — a crude authority/centrality measure. Bound the count; popular hosts have enormous in-degree.

// Sample of hosts linking into cloudflare.com
MATCH (src:HOSTNAME)-[:LINKS_TO]->(h:HOSTNAME {name: "cloudflare.com"})
WITH src LIMIT 1000
RETURN count(src) AS sampled_inbound_hosts

This is the join flat tools can't do: follow each outbound link to where it actually resolves and who routes it. Cap the link fan-out first, then traverse DNS→BGP for each.

// Outbound links → resolve each target → which networks host them (free key)
MATCH (h:HOSTNAME {name: "github.com"})-[:LINKS_TO]->(target:HOSTNAME)
WITH DISTINCT target LIMIT 200
MATCH (target)-[:RESOLVES_TO]->(ip:IPV4)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
RETURN a.name AS asn, count(DISTINCT target) AS linked_hosts
ORDER BY linked_hosts DESC
LIMIT 15

Why it's hard otherwise: this is a four-layer join — link graph → DNS → BGP announcement → ASN — across three datasets that normally live in three different tools. Here it's one statement, and the WITH DISTINCT target LIMIT 200 keeps the DNS/BGP fan-out bounded.

Shortest path between two domains

You may want to know whether two domains are connected through the link graph. A variable-length shortestPath over LINKS_TO wanders across billions of edges and times out — so bridge them explicitly instead: anchor both ends, take a bounded sample of each one's link neighbors, and intersect. A non-empty result is a two-hop bridge (both domains link to the same host).

// Do two domains bridge through a shared link target? (free key)
MATCH (a:HOSTNAME {name: "cloudflare.com"})-[:LINKS_TO]->(mid:HOSTNAME)
WITH collect(DISTINCT mid.name)[0..500] AS a_targets
MATCH (b:HOSTNAME {name: "google.com"})-[:LINKS_TO]->(shared:HOSTNAME)
WHERE shared.name IN a_targets
RETURN collect(DISTINCT shared.name)[0..25] AS shared_link_targets
LIMIT 1

Why not shortestPath(...[:LINKS_TO*1..4]...). An untyped or deep variable-length walk on the biggest edge set in the graph is exactly the query that times out. The bounded two-stage intersection above answers the same "are they connected, and through what?" question and stays inside your hop budget. For longer bridges, widen the sample or re-anchor on a shared target and repeat.


RPKI coverage

RPKI Route Origin Authorizations (ROA) are first-class nodes (~959K of them), linked to the prefixes and origin ASNs they authorize. You can study deployment and validity without pulling and parsing the RIR trust-anchor dumps yourself.

Is a network's announced space ROA-covered?

Hard with flat tools: cross-referencing announced prefixes against the RPKI repository means reconciling two separate feeds. The graph: both are nodes; ROA_AUTHORIZES_ORIGIN joins them.

// ROAs that authorize Cloudflare as an origin AS, with the max-length they permit
MATCH (roa:ROA)-[:ROA_AUTHORIZES_ORIGIN]->(a:ASN {name: "AS13335"})
RETURN roa.name AS roa, roa.maxLength AS max_length, roa.trustAnchor AS trust_anchor
ORDER BY roa
LIMIT 25

ROA trust-anchor distribution

Which RIR trust anchors back a sample of ROAs — useful for studying repository composition. Bound the scan; don't enumerate all ~959K ROAs unanchored.

// Trust-anchor breakdown across a bounded ROA sample
MATCH (roa:ROA)
WITH roa LIMIT 5000
RETURN roa.trustAnchor AS trust_anchor, count(*) AS roa_count
ORDER BY roa_count DESC

Cross-check an announcement against its authorization

Walk from an IP to its announced prefix, then ask whether a ROA authorizes that prefix — the building block of route-origin validation. Each synthesized edge is written as an explicit single hop.

// Does the prefix covering 1.1.1.1 have an authorizing ROA? (free key)
MATCH (ip:IPV4 {name: "1.1.1.1"})-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
OPTIONAL MATCH (ap)-[:ROUTES]->(a:ASN)
OPTIONAL MATCH (roa:ROA)-[:ROA_AUTHORIZES_ORIGIN]->(a)
RETURN ap.name AS announced_prefix, a.name AS origin_asn,
       count(roa) AS authorizing_roas
LIMIT 5

MOAS conflicts — the early hijack signal

A prefix announced by more than one origin AS (isMoas) is the leading early signal of a BGP hijack or route leak. CONFLICTS_WITH is the materialized conflict edge — write it as an explicit single hop.

// Find a network's MOAS prefixes and the conflicting origin ASNs
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(ap:ANNOUNCED_PREFIX)
WHERE ap.isMoas = true
MATCH (ap)-[:CONFLICTS_WITH]->(other:ASN)
RETURN ap.name AS prefix, collect(DISTINCT other.name) AS conflicting_origins
LIMIT 25

For routing-security workflows built on these signals, see BGP & RPKI.


Physical infrastructure distributions

The layer DNS-only datasets don't have: data centers, internet exchanges, and submarine cables as queryable nodes, joined to the networks that sit in them. This is where you study the physical topology of the internet.

Where is a network physically present?

AS_PRESENT_AT connects an ASN to the facilities it occupies; IX_MEMBER to the exchanges it joins.

// Cloudflare's physical footprint: facilities
MATCH (a:ASN {name: "AS13335"})-[:AS_PRESENT_AT]->(f:FACILITY)
RETURN f.name AS facility
ORDER BY facility
LIMIT 25
// Which internet exchanges is the network a member of?
MATCH (a:ASN {name: "AS13335"})-[:IX_MEMBER]->(ix:INTERNET_EXCHANGE)
RETURN ix.name AS internet_exchange
ORDER BY internet_exchange
LIMIT 25

IXP membership density

How many networks does a given exchange aggregate — a measure of regional interconnection. Anchor on the IXP and count members.

// Member-network count at a major exchange
MATCH (a:ASN)-[:IX_MEMBER]->(ix:INTERNET_EXCHANGE {name: "LINX LON1"})
RETURN ix.name AS exchange, count(DISTINCT a) AS member_networks
LIMIT 1

Submarine-cable landing topology

Subsea cables (SUBMARINE_CABLE) land at CABLE_LANDING points, which sit near FACILITY buildings. Trace a cable's landings to study coastal interconnection.

// Where does the 2Africa cable land?
MATCH (cable:SUBMARINE_CABLE {name: "2Africa"})-[:CABLE_LANDS_AT]->(lp:CABLE_LANDING)
OPTIONAL MATCH (lp)-[:LANDING_NEAR]->(f:FACILITY)
RETURN lp.name AS landing_point, collect(DISTINCT f.name) AS nearby_facilities
ORDER BY landing_point
LIMIT 25

Facility co-location — who else is in this building?

The carrier-hotel adjacency that explains a lot of real-world peering. Anchor on a facility and list its resident networks.

// Networks present in a major carrier hotel
MATCH (a:ASN)-[:AS_PRESENT_AT]->(f:FACILITY {name: "Equinix DA1 - Dallas"})
RETURN count(DISTINCT a) AS resident_networks
LIMIT 1

Prefixes mapped to cloud regions

PREFIX_IN_REGION ties address space to cloud-provider regions (aws:eu-west-1 style names) — the foundation for studying cloud address-space distribution.

// Prefixes the graph places in a specific cloud region
MATCH (p:PREFIX)-[:PREFIX_IN_REGION]->(r:CLOUD_REGION {name: "aws:eu-west-1"})
WITH p LIMIT 1000
RETURN count(p) AS sampled_prefixes

Cross-layer studies

The payoff of a pre-joined graph is the join across layers that normally live in separate tools. A few research-shaped combinations.

Tor-exit egress distribution by network

OPERATES_EXIT_NODE links an IP to its TOR_RELAY identity (which survives IP rotation). Join Tor exits to the networks that route them to see where exit capacity concentrates.

// Which networks host a sample of Tor exit IPs? (free key)
MATCH (ip:IPV4)-[:OPERATES_EXIT_NODE]->(relay:TOR_RELAY)
WITH ip LIMIT 1000
MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
RETURN a.name AS asn, count(DISTINCT ip) AS exit_ips
ORDER BY exit_ips DESC
LIMIT 15

Threat density across a network's prefixes

Aggregate threat is rolled up onto ANNOUNCED_PREFIX (threatScore, threatLevel) and onto the ASN itself (maxThreatScore, avgThreatScore, hasThreateningPrefixes) — so you can study reputation distribution without walking every IP. For per-network triage, read the ASN aggregate directly; for the contributing prefixes, anchor and bound.

// Threat-listed prefixes within a network, ordered by aggregate score
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(ap:ANNOUNCED_PREFIX)
WHERE ap.threatScore > 0
RETURN ap.name AS prefix, ap.threatScore AS score, ap.threatLevel AS level
ORDER BY score DESC
LIMIT 25

For a clean per-indicator verdict with its evidence chain, prefer CALL explain("AS13335") over hand-walking ASN → PREFIX → IP → LISTED_IN — the manual walk times out on large networks. See explain().

TLS-fingerprint reuse across IPs

EMITS_TLS_FINGERPRINT ties an IP to a JA3/JARM fingerprint — useful for studying how a server signature spreads across address space.

// IPs sharing a given JARM/JA3 fingerprint
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:EMITS_TLS_FINGERPRINT]->(fp:TLS_FINGERPRINT)
WITH fp LIMIT 1
MATCH (other:IPV4)-[:EMITS_TLS_FINGERPRINT]->(fp)
RETURN fp.name AS fingerprint, count(DISTINCT other) AS ips_with_fingerprint
LIMIT 1

Confirm the fingerprint value with CALL db.propertyKeys() or a small sample before filtering — anchor TLS_FINGERPRINT on its actual .name (the JA3/JARM hash).

Actor → ATT&CK technique map

Named threat actors (ACTOR, case-sensitive) link to the MITRE techniques they use via USES_TECHNIQUE. Study an actor's technique footprint without leaving the graph.

// MITRE ATT&CK techniques attributed to APT28
MATCH (actor:ACTOR {name: "APT28"})-[:USES_TECHNIQUE]->(t:ATTACK_PATTERN)
RETURN t.name AS technique
ORDER BY technique
LIMIT 50

For the full attribution workflow — actors sharing a technique, technique convergence — see Actor Attribution & ATT&CK.


Programmatic bulk runs

For aggregate studies you'll script the endpoint rather than click. Cypher over REST at https://graph.whisper.security/api/query:

curl -s https://graph.whisper.security/api/query \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $WHISPER_API_KEY" \
  -d '{"query":"UNWIND [\"AS13335\",\"AS3356\",\"AS15169\",\"AS2914\"] AS asn MATCH (a:ASN {name:asn})-[:BGP_NEIGHBOR]->(p:ASN) RETURN asn, count(p) AS degree ORDER BY degree DESC"}'

Each call returns columns, rows, and execution statistics as JSON, so you can fan a sample of anchors out across calls and reassemble the distribution locally. AI agents get the same surface via MCP at https://mcp.whisper.security — point any MCP client at it and it runs these queries mid-analysis (see AI & Agents).

For more patterns by workflow, see Use Cases; for the complete label/edge/property model, the Graph Schema.