BGP & RPKI
Detect MOAS conflicts and RPKI-invalid routes, read a network's hijack and leak posture, follow its AS paths, and map its peering footprint.
On this page (37)
- Quick triage
- ASN identity
- ASN scale — prefixes and peers in one shot
- Direct BGP peers
- Prefixes & allocation
- ASN prefix inventory
- IP → live route → ASN → owner (attribution)
- IP → registered allocation block & country
- Which registry allocated this block?
- Allocated space vs announced space
- MOAS & hijack detection
- Which prefixes are in conflict right now?
- Find the MOAS conflicts in a network's space
- Confirm a single prefix's origin state
- Origin history for a hijack investigation
- Read a network's hijack and route-leak posture
- RPKI ROA coverage
- Is this origin authorized to announce this prefix?
- Find a network's RPKI-authorized origins
- Announcement vs. authorization in one pass
- Paths & peering shape
- Which AS paths transit this network?
- Who are the real upstreams on those paths?
- Which networks are shrinking?
- What shape is the peering graph?
- Physical footprint
- Where does this network physically sit?
- Who else lives in this building? (shared-fate)
- Where do two networks physically meet?
- Submarine cables behind a landing region
- Cloud & vendor attribution
- Prefix → cloud region
- Which SaaS vendor owns this egress range?
- Reputation & history
- Roll up an ASN's threat posture
- Run it from the shell
- Splunk equivalents
BGP & RPKI Documentation
You're a network or BGP security engineer: you live in routing tables, peering graphs, RPKI validity, and the question of who is actually announcing this space right now. These recipes take you to the answers WhisperGraph keeps pre-joined: live announcements, BGP adjacency, observed AS paths, MOAS conflicts, RPKI ROAs, and the physical layer (the buildings, exchanges and submarine cables a network sits in). A routing investigation that normally spans RIPEstat, a looking glass, an RPKI validator and PeeringDB collapses into one Cypher statement.
Run it live: Route-Health Checker · Enrich an ASN · Trace a BGP hijack to its exposed domains · Audit an ASN's routing hygiene — guided flows that open with a live result you can rerun on your own prefix or ASN. The full list is on the Network & Routing landing.
Every recipe below is copy-paste against the Cypher/REST endpoint at https://graph.whisper.security/api/query, with the key in the X-API-Key header. The deeper attribution and physical traversals need one; sign in to get a key. New to the surface? Start with Getting Started, the Graph Schema, and Procedures.
Direction & alias landmines for this page.
ROUTESis undirected:(asn)-[:ROUTES]->(prefix)matches either arrow.ANNOUNCED_BYgoes IP →ANNOUNCED_PREFIX.CONFLICTS_WITHgoesANNOUNCED_PREFIX→ASN, andBGP_PATHgoesBGP_PATH_OBSERVATION→ASN.PREFIX_IN_REGIONhangs off the allocatedPREFIX: anchor on the prefix or on the region, never walk into it from an ASN. The canonical ASN↔ASN adjacency edge isBGP_NEIGHBOR;PEERS_WITHstill resolves as an alias, but rename it in saved queries. An ASN's network name lives on a separateASN_NAMEnode reached viaHAS_NAME(asn.nameis the AS number itself), and aROAhas noname: readroa.prefixandroa.maxLength. Never runCONTAINSonASN.name; anchor on{name:"AS…"}.
Key concepts: BGP hijacking · MOAS conflict · RPKI ROA · Route origin validation · Autonomous system · Internet exchange point.
Quick triage
ASN identity
Resolve an AS number to its registered network name. asn.name is just the number; the human-readable name is one hop away on ASN_NAME, and it is the first line any ticket about a route needs.
// ASN identity
MATCH (a:ASN {name: "AS13335"})-[:HAS_NAME]->(n:ASN_NAME)
RETURN a.name AS asn, n.name AS network_name
LIMIT 1
Returns: asn, network_name
Sample output:
[{"asn": "AS13335", "network_name": "CLOUDFLARENET - Cloudflare, Inc."}]
Costs: milliseconds; one indexed anchor and one hop; any AS number works as the seed.
From here, → ASN scale — prefixes and peers in one shot.
Read
coveragebeforeband. 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. Full contract: Coverage — what we looked at.
ASN scale — prefixes and peers in one shot
Prefix count comes from a routing collector, peer count from PeeringDB or a looking glass, and you stitch them together yourself. In the graph both are one hop off the same anchor, and the WITH between the two OPTIONAL MATCHes decides whether the query returns in milliseconds or not at all.
// Prefix count and BGP peer count for one ASN
MATCH (a:ASN {name: "AS13335"})
OPTIONAL MATCH (a)-[:ROUTES]->(p:ANNOUNCED_PREFIX)
WITH a, count(p) AS prefix_count
OPTIONAL MATCH (a)-[:BGP_NEIGHBOR]->(peer:ASN)
RETURN a.name AS asn, prefix_count, count(peer) AS peer_count
LIMIT 1
Returns: asn, prefix_count, peer_count
Sample output:
[{"asn": "AS13335", "prefix_count": 5312, "peer_count": 1284}]
Costs: milliseconds; two aggregated single hops from one anchor; a large transit network is fine as the seed because nothing is listed.
Why the
WITHmatters. It aggregates prefixes before expanding peers, so you never multiply every prefix by every peer into a Cartesian product. Aggregate, then expand. For the rolled-up posture of the AS, useCALL explain("AS13335")(below).
From here, → Direct BGP peers.
Direct BGP peers
List the ASNs that share a BGP session with a network. A peer list is the first thing to check when a route appears from an unexpected direction, and it is the entry point to the AS-path recipes below.
// Direct BGP neighbours of an ASN
MATCH (a:ASN {name: "AS13335"})-[:BGP_NEIGHBOR]->(peer:ASN)
RETURN peer.name AS peer LIMIT 20
Returns: peer
Sample output:
[{"peer": "AS31"}, {"peer": "AS49"}, {"peer": "AS112"}, {"peer": "AS174"}]
Costs: milliseconds; one anchored hop; count before you list, because the largest transit carriers carry thousands of adjacencies.
Tip.
BGP_NEIGHBORreflects observed session data: a mutual session may appear as edges in both directions or only one, depending on how it was collected. Size the neighbourhood first:
// Peering degree before pulling the full list
MATCH (a:ASN {name: "AS3356"})-[:BGP_NEIGHBOR]->(peer:ASN)
RETURN count(peer) AS peer_count
LIMIT 1
Sample output:
[{"peer_count": 6196}]
From here, → Which AS paths transit this network? turns adjacency into observed paths.
Prefixes & allocation
ASN prefix inventory
List the prefixes a network is currently announcing. This is the live BGP view; the registry allocation is a different node and the next recipe.
// All prefixes announced by an ASN
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
RETURN p.name AS prefix LIMIT 20
Returns: prefix
Sample output:
[
{"prefix": "1.0.0.0/24"},
{"prefix": "1.1.1.0/24"},
{"prefix": "5.10.214.0/24"}
]
Costs: milliseconds; one anchored hop; keep the LIMIT, a content network announces thousands of prefixes.
Tip.
ANNOUNCED_PREFIXis the live BGP view;REGISTERED_PREFIXis the RIR allocation. They are often different sizes: one allocation is frequently announced as several more-specifics.
From here, → IP → live route → ASN → owner (attribution).
IP → live route → ASN → owner (attribution)
An IP-to-ASN lookup gives you a number; mapping it to the announcing prefix and the registered network name is two more services. In the graph it is one traversal from the IP through the announced prefix to the AS and its name. Sign in to run this one.
// IP → announcing prefix → ASN → network name
MATCH (ip:IPV4 {name: "1.1.1.1"})-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network
LIMIT 5
Returns: ip, prefix, asn, network
Sample output:
[{"ip": "1.1.1.1", "prefix": "1.1.1.0/24", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."}]
Costs: milliseconds; three explicit single hops from an indexed address; an unrouted address returns no row.
From here, → IP → registered allocation block & country.
IP → registered allocation block & country
The RIR-assigned block (not the BGP announcement), plus the country it was registered in. A residency or abuse-contact question starts here, not at the announcement.
// Registered allocation block + registration country for an IP
MATCH (ip:IPV4 {name: "1.1.1.1"})-[:BELONGS_TO]->(rp:REGISTERED_PREFIX)
-[:HAS_COUNTRY]->(co:COUNTRY)
RETURN ip.name AS ip, rp.name AS allocation, co.name AS country
LIMIT 5
Returns: ip, allocation, country
Sample output:
[{"ip": "1.1.1.1", "allocation": "1.1.1.0/24", "country": "AU"}]
Costs: milliseconds; two single hops from an indexed address.
Tip. This reflects where the block was registered, not where traffic is served. For anycast (which
1.1.1.1is), the registration country is the operator's home jurisdiction. The registry itself sits on the block asrp.rir(next recipe).
From here, → Which registry allocated this block?.
Which registry allocated this block?
The RIR and the allocation date live on the REGISTERED_PREFIX node itself, so the registry behind a block is a property read, not a traversal. Escalation paths and jurisdiction both start with this answer.
// Registry and allocation date for a registered block
MATCH (rp:REGISTERED_PREFIX {name: "1.1.1.0/24"})
RETURN rp.name AS block, rp.rir AS registry, rp.registrationDate AS registered
LIMIT 1
Returns: block, registry, registered
Sample output:
[{"block": "1.1.1.0/24", "registry": "APNIC", "registered": 1313017955000}]
Costs: milliseconds; one indexed anchor and no hops; anchor on the exact allocated CIDR, not on a more-specific announcement.
Tip.
registeredis epoch milliseconds. The five registries appear asAFRINIC,APNIC,ARIN,LACNICandRIPENCC; read the value off the block rather than scanning theRIRlabel.
From here, → Allocated space vs announced space.
Allocated space vs announced space
A block is registered to somebody at a regional registry, and separately it is (or is not) announced into the routing table. The gap between those two facts is the finding: allocated-but-unannounced space is a hijack target, and announced-but-unallocated space is a different problem.
// Allocation record and routing status for the same blocks
UNWIND ["1.0.0.0/24", "8.8.8.0/24", "196.10.141.0/24"] AS cidr
OPTIONAL MATCH (rp:REGISTERED_PREFIX {name: cidr})
OPTIONAL MATCH (ap:ANNOUNCED_PREFIX {name: cidr})
RETURN cidr AS block, rp.rir AS allocated_by, ap.rpkiStatus AS rpki,
ap.isMoas AS moas
LIMIT 10
Returns: block, allocated_by, rpki, moas
Sample output:
[
{"block": "1.0.0.0/24", "allocated_by": "APNIC", "rpki": "valid", "moas": false},
{"block": "8.8.8.0/24", "allocated_by": "ARIN", "rpki": "valid", "moas": false},
{"block": "196.10.141.0/24", "allocated_by": "AFRINIC", "rpki": null, "moas": null}
]
Costs: milliseconds; one indexed lookup per element on each side, no hops; swap in your own CIDR list.
Tip. Keep both matches
OPTIONAL: a plainMATCHon either side drops exactly the rows where the two disagree, which are the only rows you were looking for. The third row above is the interesting shape: allocated, and nothing announced at that exact CIDR, so every routing column is null. That is either dormant space or space announced at a different granularity, and both are worth checking.
From here, → Which prefixes are in conflict right now?.
MOAS & hijack detection
Which prefixes are in conflict right now?
Multi-origin state is live routing data: it appears and settles as routes shift. Lead with the discovery form, which reads the CONFLICTS_WITH edge directly and finds whatever is in conflict at query time, without you naming a network first.
// Prefixes currently announced by more than one autonomous system
MATCH (ap:ANNOUNCED_PREFIX)-[:CONFLICTS_WITH]->(a:ASN)
WITH ap, a LIMIT 15
RETURN ap.name AS prefix, ap.isMoas AS is_moas,
ap.moasIsLegitimate AS looks_legitimate, a.name AS conflicting_asn
LIMIT 15
Returns: prefix, is_moas, looks_legitimate, conflicting_asn
Sample output:
[
{"prefix": "164.163.138.0/24", "is_moas": true, "looks_legitimate": false, "conflicting_asn": "AS1"},
{"prefix": "191.241.191.0/24", "is_moas": true, "looks_legitimate": false, "conflicting_asn": "AS10"},
{"prefix": "195.74.62.0/23", "is_moas": true, "looks_legitimate": false, "conflicting_asn": "AS10"},
{"prefix": "38.22.219.0/24", "is_moas": true, "looks_legitimate": false, "conflicting_asn": "AS100"}
]
Costs: milliseconds; a bounded read of the conflict edge, no seed; the WITH ... LIMIT keeps it a sample.
Tip.
isMoasis the yes/no;moasIsLegitimateis the triage column, because plenty of prefixes are announced by two networks on purpose (anycast, multi-homing). Anullthere means the conflict has not been assessed yet: read it as unreviewed, never as "looks fine".CONFLICTS_WITHnames the competing origins; the announcer itself is excluded, so every ASN listed is a genuine competing origin. Any specific example prefix will eventually settle, which is why this form is the one to build on, and the anchored form below is the one to schedule against your own space.
From here, → Find the MOAS conflicts in a network's space.
Find the MOAS conflicts in a network's space
A Multi-Origin AS conflict has a shape in the graph: a MOAS flag on the ANNOUNCED_PREFIX and a CONFLICTS_WITH edge naming each competing origin AS.
// Prefixes in an ASN's footprint that are in MOAS conflict, and who else announces them
MATCH (a:ASN {name: "AS36014"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
WHERE p.isMoas = true
MATCH (p)-[:CONFLICTS_WITH]->(other:ASN)
RETURN p.name AS prefix, p.rpkiStatus AS rpki_status, p.moasIsLegitimate AS looks_legitimate,
collect(DISTINCT other.name) AS conflicting_origins
LIMIT 25
Returns: prefix, rpki_status, looks_legitimate, conflicting_origins
Sample output (captured 2026-09-03):
[
{"prefix": "64.234.228.0/22", "rpki_status": "valid", "looks_legitimate": false, "conflicting_origins": ["AS11059", "AS36014"]},
{"prefix": "162.251.112.0/22", "rpki_status": "valid", "looks_legitimate": false, "conflicting_origins": ["AS11059", "AS36014"]},
{"prefix": "206.80.235.0/24", "rpki_status": "not-found", "looks_legitimate": false, "conflicting_origins": ["AS11059", "AS36014"]}
]
Costs: milliseconds; the boolean filter runs before the conflict hop, so the expansion only touches prefixes already flagged; most networks return nothing, so pick a seed from the discovery form above.
Most networks return nothing here, and that is the normal case rather than a broken query. A MOAS conflict is an exception: the graph holds 2,794
CONFLICTS_WITHedges against 1.4M announced prefixes, and a well-run network has none. An empty result means this network is not in conflict, not we have no data.rpki_status: "not-found"means the space is unsigned, which is a finding of its own: nothing in RPKI says which of the two origins is the rightful one.
From here, → Confirm a single prefix's origin state.
Confirm a single prefix's origin state
Anchor straight on the prefix when you already have it from an alert. collect(DISTINCT a.name) is the origin set: more than one name in it is a multi-origin announcement, read off the routing edges rather than a flag, and the RPKI columns sit on the same node.
// Origin set, MOAS flag and RPKI state for a specific prefix
MATCH (a:ASN)-[:ROUTES]->(p:ANNOUNCED_PREFIX {name: "1.1.1.0/24"})
RETURN p.name AS prefix, collect(DISTINCT a.name) AS announcing_origins,
p.isMoas AS is_moas, p.rpkiStatus AS rpki_status,
p.roaAsn AS roa_authorized_asn, p.roaMaxLength AS roa_max_length
LIMIT 1
Returns: prefix, announcing_origins, is_moas, rpki_status, roa_authorized_asn, roa_max_length
Sample output:
[{"prefix": "1.1.1.0/24", "announcing_origins": ["AS13335"], "is_moas": false,
"rpki_status": "valid", "roa_authorized_asn": 13335, "roa_max_length": 24}]
Costs: milliseconds; one indexed anchor and one inbound hop; anchor on the announced CIDR exactly as it appears in the table.
Tip. The row to escalate is one where
rpki_statusisinvalid: the announcement is not covered by the ROA thatroa_authorized_asnnames.p.rpkiInvalidReasonandp.moasIsLegitimateare populated only when they apply, so add them to theRETURNwhen you are triaging an invalid or multi-origin prefix, and expect them blank on a clean one.
From here, → Origin history for a hijack investigation.
Origin history for a hijack investigation
When a MOAS needs context, pull the timestamped BGP history to see which ASNs have announced the space over time. whisper.history.bgp is the routing-only shape with stable columns; the multi-shape whisper.history is documented on whisper.history(). Sign in to run it.
// BGP origin history for a prefix, one row per observation window
CALL whisper.history.bgp("1.1.1.0/24")
YIELD origin, prefix, startTime, endTime, visibility
RETURN origin, prefix, startTime, endTime, visibility
LIMIT 10
Returns: origin, prefix, startTime, endTime, visibility
Sample output:
[
{"origin": "AS226", "prefix": "1.1.1.0/24", "startTime": "2016-02-05T00:00:00", "endTime": "2016-02-16T23:59:59", "visibility": 0.1194},
{"origin": "AS226", "prefix": "1.1.1.0/24", "startTime": "2016-02-17T00:00:00", "endTime": "2016-02-28T23:59:59", "visibility": 0.1343}
]
Costs: a procedure call, not a traversal; history over a whole network is slower than over one prefix, so keep the LIMIT.
Tip. Multiple distinct
originASNs over time can mean a legitimate IP-space transfer or a historical hijack; cross-reference each origin against the prefix's authorized ROA below.visibilityis the share of vantage points that saw the announcement, so a low-visibility origin was a partial or short-lived event. The same call works on an ASN or an IP.
From here, → Read a network's hijack and route-leak posture.
Read a network's hijack and route-leak posture
You want a routing-behaviour read on a network in one lookup: how big it is, how often it has originated space it should not, and whether it has been observed leaking routes, without subscribing to a routing-reputation service. All of it is precomputed on the ASN node.
// Routing posture for one network, straight off the node
MATCH (a:ASN {name: "AS13335"})
RETURN a.name AS asn, a.asRank AS as_rank, a.coneAsns AS customer_cone,
a.hijackPostureScore AS hijack_posture,
a.hijackOriginMismatchCount AS origin_mismatches,
a.routeLeakCount AS route_leaks,
a.overallThreatLevel AS threat_level
LIMIT 1
Returns: asn, as_rank, customer_cone, hijack_posture, origin_mismatches, route_leaks, threat_level
Sample output:
[{"asn": "AS13335", "as_rank": 64, "customer_cone": 963, "hijack_posture": 0.5664,
"origin_mismatches": 3, "route_leaks": 18, "threat_level": "NONE"}]
Costs: milliseconds; one indexed anchor and a property read, no hops; anchor on :ASN, the same properties are not on ASN_NAME.
Tip. Read these together, never alone. A large network accumulates a handful of origin mismatches and route leaks through scale and operational churn, so the raw counts mean little until you weigh them against
coneAsnsandasRank.hijackPostureScoreis the graph's own combination of those factors;overallThreatLevelfolds in feed evidence on the space the network announces. The row worth escalating is a small network (low rank, small cone) with a high posture score, because that combination has no benign explanation from scale. The same anchor also carriesrouteLeakTypesandreputationBulletproofScoreif you want to go further.
From here, → Is this origin authorized to announce this prefix?.
RPKI ROA coverage
Is this origin authorized to announce this prefix?
You normally run a separate RPKI validator, then reconcile its answer against the live origin by hand. In the graph the ROA node, the prefix it authorizes (ROA_AUTHORIZES_PREFIX) and the origin it authorizes (ROA_AUTHORIZES_ORIGIN) sit alongside the routing data, so one traversal tells you both.
// RPKI ROAs covering a prefix, and the origin AS each authorizes
MATCH (roa:ROA)-[:ROA_AUTHORIZES_PREFIX]->(p:PREFIX {name: "1.1.1.0/24"})
WHERE roa.authSource = "rpki-roa"
MATCH (roa)-[:ROA_AUTHORIZES_ORIGIN]->(a:ASN)
RETURN p.name AS prefix, a.name AS authorized_origin,
roa.maxLength AS max_length, roa.trustAnchor AS trust_anchor,
roa.validUntil AS valid_until
LIMIT 10
Returns: prefix, authorized_origin, max_length, trust_anchor, valid_until
Sample output (captured 2026-10-06):
[{"prefix": "1.1.1.0/24", "authorized_origin": "AS13335", "max_length": 24, "trust_anchor": "apnic", "valid_until": "2026-10-09T14:17:43Z"}]
Costs: milliseconds; one indexed prefix anchor and two single hops; anchor on the exact covered CIDR.
Empty result: no rows means no ROA covers that exact prefix, which is unsigned space, a finding in its own right rather than a pass. Check the announcement's own
rpkiStatus(next recipes) before you conclude anything. Zero rows is never a verdict.
Tip.
maxLengthis the RPKI safety net: an origin authorized for1.1.1.0/24withmaxLength = 24is not authorized to announce a more-specific1.1.1.128/25. A more-specific announcement that exceedsmaxLengthis a classic sub-prefix hijack, invalid even though the origin matches.
Two kinds of
ROA. The label also holds route objects from the internet routing registries (authSource: "irr-route"), which are registry declarations rather than signed RPKI authorizations, so the RPKI recipes on this page keep toauthSource = "rpki-roa".
From here, → Find a network's RPKI-authorized origins.
Find a network's RPKI-authorized origins
Enumerate every prefix the ROAs grant to an ASN as origin: the authoritative "who should be announcing this". Read the prefix and its permitted length off the ROA node itself, which is instant, rather than walking a second edge out of tens of thousands of ROAs.
// Which prefixes this ASN is authorized to originate, and to what length
MATCH (roa:ROA)-[:ROA_AUTHORIZES_ORIGIN]->(a:ASN {name: "AS13335"})
WHERE roa.authSource = "rpki-roa"
RETURN a.name AS asn, roa.prefix AS authorized_prefix,
roa.maxLength AS max_length, roa.trustAnchor AS trust_anchor
LIMIT 25
Returns: asn, authorized_prefix, max_length, trust_anchor
Sample output (captured 2026-10-06):
[
{"asn": "AS13335", "authorized_prefix": "102.219.82.0/24", "max_length": 24, "trust_anchor": "afrinic"},
{"asn": "AS13335", "authorized_prefix": "154.193.133.0/24", "max_length": 24, "trust_anchor": "afrinic"},
{"asn": "AS13335", "authorized_prefix": "154.193.184.0/24", "max_length": 24, "trust_anchor": "afrinic"}
]
Swap the property reads for count(roa) to size the authorization set before you list it:
// How many RPKI ROAs authorize this ASN as origin
MATCH (r:ROA)-[:ROA_AUTHORIZES_ORIGIN]->(a:ASN {name: "AS13335"})
WHERE r.authSource = "rpki-roa"
RETURN a.name AS asn, count(r) AS authorizing_roas
LIMIT 1
Sample output (captured 2026-10-06):
[{"asn": "AS13335", "authorizing_roas": 5348}]
Costs: milliseconds; one inbound hop from an indexed ASN plus property reads; do not join ROA_AUTHORIZES_PREFIX onto this set, the product of a large network's ROAs and their prefixes is too wide to return.
Empty result: no rows means no ROA names this ASN as an origin. That is the state RPKI-invalid announcements come from, so read it alongside the next recipe rather than as "no data". Zero rows is never a verdict.
Tip. Read the ROA's identity off
roa.prefixandroa.maxLength, notroa.name, which is not populated on these nodes and returns a column of nulls. Without theauthSourcefilter the count also takes in routing-registry route objects, which for this network outnumber its RPKI ROAs several times over and carry nomaxLengthortrustAnchor.
From here, → Announcement vs. authorization in one pass.
Announcement vs. authorization in one pass
You don't have to run the join yourself: RPKI validation state is precomputed on every ANNOUNCED_PREFIX, so a network's unauthorized announcements are a single anchored read, and rpkiInvalidReason tells you which of the two very different failures each one is.
// Announcements a network makes that RPKI marks invalid, and why
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
WHERE p.rpkiStatus = "invalid"
RETURN p.name AS prefix, p.rpkiStatus AS rpki_status, p.rpkiInvalidReason AS why,
p.roaAsn AS roa_authorized_asn, p.roaMaxLength AS roa_max_length
LIMIT 25
Returns: prefix, rpki_status, why, roa_authorized_asn, roa_max_length
Sample output (captured 2026-10-06):
[
{"prefix": "103.21.244.0/24", "rpki_status": "invalid", "why": "ORIGIN_MISMATCH", "roa_authorized_asn": 0, "roa_max_length": 23},
{"prefix": "162.158.208.0/24", "rpki_status": "invalid", "why": "MAX_LENGTH", "roa_authorized_asn": 13335, "roa_max_length": 22},
{"prefix": "172.68.180.0/24", "rpki_status": "invalid", "why": "MAX_LENGTH", "roa_authorized_asn": 13335, "roa_max_length": 22},
{"prefix": "2606:4700:7000::/48", "rpki_status": "invalid", "why": "ORIGIN_MISMATCH", "roa_authorized_asn": 0, "roa_max_length": 48},
{"prefix": "2a09:bac0:745::/48", "rpki_status": "invalid", "why": "MAX_LENGTH", "roa_authorized_asn": 13335, "roa_max_length": 29}
]
Costs: milliseconds; one anchored hop with a property filter, no ROA join at all; any network works, and a clean one returns nothing.
Tip. Read the two reasons apart.
MAX_LENGTHmeans a ROA exists and authorizes this origin, but the announcement is more specific than it permits: usually an operator misconfiguration, usually fixable in an afternoon.ORIGIN_MISMATCHmeans the ROA authorizes somebody else (roa_authorized_asn), or nobody (0), for this space, and that is the row to wake someone up for. The Trace a BGP hijack workflow runs this check and then walks the exposed hostnames for you.
From here, → Which AS paths transit this network?.
Paths & peering shape
Which AS paths transit this network?
You know a network is in your dependency chain, and you want the observed AS paths that actually run through it rather than its adjacency list. Paths tell you who the real upstreams are; the peering table only tells you who is adjacent.
// Observed AS paths that traverse a given network
MATCH (b:BGP_PATH_OBSERVATION)-[:BGP_PATH]->(a:ASN {name: "AS13335"})
RETURN b.name AS as_path
LIMIT 10
Returns: as_path
Sample output:
[
{"as_path": "132825-174-12956-6568-13335"},
{"as_path": "132825-174-1299-13335"},
{"as_path": "132825-174-1299-13335-199610"}
]
Count them to size the network's position in the routing system:
// How many observed paths run through this network
MATCH (b:BGP_PATH_OBSERVATION)-[:BGP_PATH]->(a:ASN {name: "AS13335"})
RETURN count(b) AS paths_through_asn
LIMIT 1
Sample output:
[{"paths_through_asn": 44914}]
Costs: milliseconds; one inbound hop from an indexed ASN; a well-connected transit network sits on tens of thousands of paths, so list with a LIMIT and count separately.
Tip. A path observation's
nameis the AS sequence itself, hyphen-joined, with the origin AS last: anchor on a network and every path you get back contains its number. The same logical path seen from two vantage points is two separate nodes, so counts are observations rather than distinct routes.
From here, → Who are the real upstreams on those paths?.
Who are the real upstreams on those paths?
Bound the path set first, then expand it back out to every network on the same paths. The networks near the top of the list, after the anchor itself, are the transit providers actually carrying its traffic, which is the practical upstream question a resilience review asks.
// The networks that most often appear on the same paths
MATCH (b:BGP_PATH_OBSERVATION)-[:BGP_PATH]->(a:ASN {name: "AS201814"})
WITH b LIMIT 200
MATCH (b)-[:BGP_PATH]->(t:ASN)
RETURN t.name AS asn_on_path, count(*) AS paths
ORDER BY paths DESC
LIMIT 10
Returns: asn_on_path, paths
Sample output:
[
{"asn_on_path": "AS201814", "paths": 200},
{"asn_on_path": "AS207208", "paths": 196},
{"asn_on_path": "AS174", "paths": 154},
{"asn_on_path": "AS13830", "paths": 123},
{"asn_on_path": "AS971", "paths": 123},
{"asn_on_path": "AS1299", "paths": 106}
]
Costs: milliseconds; two single hops with a WITH b LIMIT between them; always bound the path set before the second BGP_PATH hop.
Tip. Pair this with Direct BGP peers: a network that appears on most of the anchor's paths but is not a direct neighbour is a transit dependency one step removed, and that is the one a single-provider outage takes with it.
From here, → Which networks are shrinking?.
Which networks are shrinking?
Reviewing a supplier's connectivity or scoring an acquisition target's network, you want an early read on whether a network is losing peers and prefixes rather than growing. The graph carries that as a curated signal on the ASN node.
// Networks the graph flags as losing routing presence
MATCH (a:ASN)-[:HAS_SIGNAL]->(:THREAT_SIGNAL_TYPE {name: "asn-death-spiral"})
WITH a LIMIT 10
OPTIONAL MATCH (a)-[:HAS_NAME]->(n:ASN_NAME)
RETURN a.name AS asn, n.name AS operator
LIMIT 10
Returns: asn, operator
Sample output (captured 2026-09-03):
[
{"asn": "AS135155", "operator": "TALTOLANET-AS-AP - TALTOLA.NET"},
{"asn": "AS210135", "operator": "YUG-TELECOM-K-AS - Yug-Telecom-K Ltd."},
{"asn": "AS210240", "operator": "NTC-AS - New Communication Technologies LLC"}
]
Costs: milliseconds; anchored on the signal node, one hop plus an optional name hop; no seed needed.
Tip. This is a small, deliberately conservative set, never a leaderboard, and it can be empty: no rows means no network carries the signal today. Treat a hit as a prompt to look at the network's prefix count and peering directly, and pair it with
CALL whisper.history.bgp("AS135155")to see the trajectory. The other operator-level signals on the same edge arebulletproof-hosting,critical-infrastructure,ddos-mitigationandsatellite-network; anchor on an ASN andOPTIONAL MATCH (a)-[:HAS_SIGNAL]->(s)to read whichever it carries.
From here, → What shape is the peering graph?.
What shape is the peering graph?
Writing up how concentrated the routing system is, you want the degree distribution rather than a per-network lookup: how many networks have one upstream and no customers, and how few sit at the centre.
// Peering degree distribution across every network
CALL whisper.bgpDegreeDistribution()
YIELD inDegree, outDegree, asnCount
RETURN inDegree, outDegree, asnCount
LIMIT 10
Returns: inDegree, outDegree, asnCount
Sample output:
[
{"inDegree": 0, "outDegree": 0, "asnCount": 1},
{"inDegree": 0, "outDegree": 1, "asnCount": 2},
{"inDegree": 0, "outDegree": 5, "asnCount": 1}
]
Costs: milliseconds; a precomputed histogram served by a procedure, no traversal and no arguments.
Tip. One row per
(inDegree, outDegree)pair, so it is a histogram rather than a ranking; sumasnCountacross rows to sanity-check it against the network population. Pair it withwhisper.topAsnsByPrefixCount(n)when you want the head of the distribution by name instead of by shape.
From here, → Where does this network physically sit?.
Physical footprint
This is the layer DNS-only and routing-only tools don't have at all: which buildings, exchanges, and cables a network is physically tied to.
Where does this network physically sit?
PeeringDB facility and IX membership is a separate portal with no link to your routing or threat data. In the graph, AS_PRESENT_AT (facilities) and IX_MEMBER (exchanges) hang directly off the ASN node.
// Facilities (data centers) a network is present in
MATCH (a:ASN {name: "AS13335"})-[:AS_PRESENT_AT]->(f:FACILITY)
RETURN f.name AS facility LIMIT 25
Returns: facility
Sample output:
[{"facility": "Equinix SV8 - Silicon Valley, Palo Alto"}, {"facility": "Equinix SV1/SV5/SV10 - Silicon Valley, San Jose"}]
// IXPs a network is a member of, and the facilities hosting those IXPs
MATCH (a:ASN {name: "AS13335"})-[:IX_MEMBER]->(ix:INTERNET_EXCHANGE)
OPTIONAL MATCH (ix)-[:IX_HOSTED_AT]->(f:FACILITY)
RETURN ix.name AS ixp, collect(DISTINCT f.name) AS hosted_in LIMIT 25
Sample output:
[{"ixp": "MD-IX", "hosted_in": ["COLO-54 Moldtelecom", "Data City - Moldtelecom"]}]
Costs: milliseconds; one anchored hop each, plus an optional facility hop for the exchanges; keep IX_HOSTED_AT optional, smaller exchanges carry no facility record.
Tip. Facility and IX overlap is how you reason about shared fate and physical blast radius: two ASNs that meet only at a single exchange have a very different risk profile from two that share a cage in the same building.
From here, → Who else lives in this building? (shared-fate).
Who else lives in this building? (shared-fate)
Pivot from a facility to every network present in it: a physical co-tenancy view, and the count that tells you whether a building is a concentration point.
// Every ASN present in a given facility
MATCH (f:FACILITY {name: "Equinix DA1 - Dallas"})<-[:AS_PRESENT_AT]-(a:ASN)
RETURN f.name AS facility, count(a) AS networks_present
LIMIT 1
Returns: facility, networks_present
Sample output:
[{"facility": "Equinix DA1 - Dallas", "networks_present": 510}]
// …and list them
MATCH (f:FACILITY {name: "Equinix DA1 - Dallas"})<-[:AS_PRESENT_AT]-(a:ASN)
RETURN a.name AS asn LIMIT 25
Costs: milliseconds; one inbound hop from an indexed facility name; facility names are exact strings, copy them from the previous recipe's output.
From here, → Where do two networks physically meet?.
Where do two networks physically meet?
Find the facilities or exchanges two ASNs have in common: useful for explaining why a peering relationship exists, or for mapping concentration risk between two providers you treat as independent.
// Facilities shared by two networks
MATCH (a:ASN {name: "AS13335"})-[:AS_PRESENT_AT]->(f:FACILITY)<-[:AS_PRESENT_AT]-(b:ASN {name: "AS15169"})
RETURN f.name AS shared_facility LIMIT 25
Returns: shared_facility
Sample output:
[{"shared_facility": "Equinix SV8 - Silicon Valley, Palo Alto"}, {"shared_facility": "Equinix SV1/SV5/SV10 - Silicon Valley, San Jose"}]
// IXPs where two networks are both members
MATCH (a:ASN {name: "AS13335"})-[:IX_MEMBER]->(ix:INTERNET_EXCHANGE)<-[:IX_MEMBER]-(b:ASN {name: "AS15169"})
RETURN ix.name AS shared_ixp LIMIT 25
Costs: milliseconds; two anchored networks joined through one intermediate node; two networks with no shared building return nothing, which is the answer.
Tip. Two ASNs that are
BGP_NEIGHBORs and share an exchange are almost certainly peering over that fabric. Combine the routing adjacency from the triage section with this physical overlap to explain a peering relationship, not just assert it.
From here, → Submarine cables behind a landing region.
Submarine cables behind a landing region
Subsea cables are the deepest physical layer: SUBMARINE_CABLE → CABLE_LANDS_AT → CABLE_LANDING, with LANDING_NEAR tying a landing point to a nearby facility. A single cable cut degrades a region, so this is the concentration question behind every regional dependency.
// Landing points for a submarine cable, and nearby facilities
MATCH (c:SUBMARINE_CABLE {name: "2Africa"})-[:CABLE_LANDS_AT]->(l:CABLE_LANDING)
OPTIONAL MATCH (l)-[:LANDING_NEAR]->(f:FACILITY)
RETURN c.name AS cable, l.name AS landing, collect(DISTINCT f.name) AS near_facilities
LIMIT 25
Returns: cable, landing, near_facilities
Sample output:
[
{"cable": "2Africa", "landing": "Duynefontein, South Africa", "near_facilities": ["Africa Data Centres, Cape Town CPT1, South Africa", "Teraco CT1 Cape Town, South Africa", "OADC CPT1 - Cape Town"]},
{"cable": "2Africa", "landing": "Dakar, Senegal", "near_facilities": ["ONIX Senegal", "PAIX Dakar"]}
]
Costs: milliseconds; one anchored hop plus an optional facility hop; keep LANDING_NEAR optional, some landings have no facility on record.
Tip. Chain
LANDING_NEAR→FACILITY←AS_PRESENT_ATto reason about which networks sit closest to a cable landing. For the full dependency-mapping angle, see Infrastructure & Supply Chain.
From here, → Prefix → cloud region.
Cloud & vendor attribution
Prefix → cloud region
An IP traced to a large prefix and you need to confirm whether it sits in a specific cloud region, say AWS in Ireland. PREFIX_IN_REGION hangs off the allocated PREFIX, so anchor on the prefix (or on the region), never on the network that routes it.
// Which cloud region owns this prefix
MATCH (p:PREFIX {name: "108.128.0.0/13"})-[:PREFIX_IN_REGION]->(r:CLOUD_REGION)
RETURN p.name AS prefix, r.name AS cloud_region
LIMIT 5
Returns: prefix, cloud_region
Sample output:
[{"prefix": "108.128.0.0/13", "cloud_region": "aws:eu-west-1"}]
Turn it around to see which networks announce a region's space:
// Networks announcing prefixes the graph places in one cloud region
MATCH (p:PREFIX)-[:PREFIX_IN_REGION]->(r:CLOUD_REGION {name: "aws:eu-west-1"})
WITH p LIMIT 200
MATCH (a:ASN)-[:ROUTES]->(p)
RETURN a.name AS asn, count(DISTINCT p) AS prefixes
ORDER BY prefixes DESC LIMIT 10
Sample output:
[{"asn": "AS16509", "prefixes": 125}]
Costs: milliseconds; anchored on the prefix or the region, one hop, then a bounded ROUTES join; anchor on a block the provider actually publishes, not a more-specific announcement.
Empty result: region names are provider-prefixed (
aws:eu-west-1), and coverage is strongest for the major providers. Zero rows means Whisper has not mapped that prefix to a tracked region. It never means the network has no cloud presence. Zero rows is never a verdict.
Tip. Starting from the network and walking
ROUTESintoPREFIX_IN_REGIONis the shape to avoid: anchor the region edge on the prefix or the region and join the announcing ASN onto that bounded set, as the second query does.
From here, → Which SaaS vendor owns this egress range?.
Which SaaS vendor owns this egress range?
A connection arrives from an unfamiliar range and you suspect it is a SaaS product's egress (Zoom, Okta, Stripe, an iCloud Private Relay exit) rather than a direct user. The graph maps published vendor egress ranges to a canonical VENDOR, so you can name the service behind the range instead of treating it as anonymous.
// Published egress ranges that belong to a SaaS vendor
MATCH (p:PREFIX)-[:DELEGATED_TO]->(v:VENDOR {name: "zoom"})
RETURN v.displayName AS vendor, v.category AS category,
count(DISTINCT p) AS egress_ranges,
collect(DISTINCT p.name)[0..6] AS sample_ranges
LIMIT 1
Returns: vendor, category, egress_ranges, sample_ranges
Sample output:
[{"vendor": "Zoom", "category": "saas", "egress_ranges": 49, "sample_ranges": ["170.114.0.0/16", "159.124.0.0/16", "144.195.0.0/16", "206.247.0.0/16", "149.137.0.0/17", "168.140.0.0/17"]}]
Costs: milliseconds; one inbound hop from an indexed vendor slug; the slug is lowercase (zoom, okta, stripe, apple, cloudflare, fastly).
Tip. To go the other way and ask which vendor owns this range, anchor on the prefix:
MATCH (p:PREFIX {name: "170.114.0.0/16"})-[:DELEGATED_TO]->(v:VENDOR) RETURN v.displayName, v.category.category(saas,cloud,esp) tells you whether you are looking at an application vendor, a hosting platform or an email sender.MATCH (v:VENDOR) RETURN v.name, v.category LIMIT 60lists the catalogue; it is a small reference label and safe to scan.
From here, → Roll up an ASN's threat posture.
Reputation & history
Roll up an ASN's threat posture
Don't walk ASN → prefix → IP → LISTED_IN by hand on a large network. The reconciled rollup is precomputed on the ASN node, and explain() returns the inspectable reasoning behind it.
// Precomputed threat rollup on the ASN node
MATCH (a:ASN {name: "AS60729"})
RETURN a.name AS asn, a.overallThreatLevel AS level,
a.maxThreatScore AS max_score, a.avgThreatScore AS avg_score,
a.hasThreateningPrefixes AS has_bad_prefixes
LIMIT 1
Returns: asn, level, max_score, avg_score, has_bad_prefixes
Sample output:
[{"asn": "AS60729", "level": "LOW", "max_score": 13.2, "avg_score": 6.0, "has_bad_prefixes": true}]
// The reasoning: explanation, factors and the weighted breakdown
CALL explain("AS60729")
YIELD indicator, type, found, explanation, factors, breakdown
RETURN indicator, type, found, explanation, factors, breakdown
LIMIT 1
Sample output (factors elided):
[{
"indicator": "AS60729",
"type": "asn",
"found": true,
"explanation": "AS60729 (TORSERVERS-NET - Stiftung Erneuerbare Freiheit, DE) has a reputation score of 44.2 (SUSPICIOUS). This ASN shows suspicious characteristics.",
"breakdown": {"threatDensityScore": 17.7, "graphMetricsScore": 65.0, "historicalScore": 75.0,
"prefixAgeScore": 20.0, "graphListedIps": 185, "graphAnnouncedIpv4": 768,
"graphDensityRatio": 0.2409, "graphCoverage": "computed",
"reputationScore": 44.19, "reputationCategory": "SUSPICIOUS"}
}]
Costs: milliseconds; one property read and one procedure call, no traversal; any ASN works as the seed. Both scores move as feeds and BGP evidence update, so treat the figures above as illustrative rather than a fixed reading for this ASN.
Name your columns. For an ASN,
explain()reports a reputation composite inexplanationandbreakdown(higher is more trustworthy), and itsscoreandlevelcolumns are not the verdict for this indicator type.YIELDthe columns above, and read the response'sadvisorieschannel, which says the same thing in the payload. For a per-address measure you can compare across networks,CALL whisper.asnThreatDensity("AS60729")returnslistedIps,announcedIpv4anddensityRatio.
Tip. An AS with a modest composite but a high
threatDensityScoreis actively hosting threats even if its history is clean.hasThreateningPrefixes = trueis your cue to drill into the individualANNOUNCED_PREFIX.threatLevelvalues. The score reflects whichever feeds are currently loaded, so treat it as a live read, not a fixed number.
From here, → Which prefixes are in conflict right now? to start the loop again from the routing side.
Run it from the shell
Every recipe is just a POST. Here's the RPKI-invalid check over REST:
curl -s https://graph.whisper.security/api/query \
-H "Content-Type: application/json" \
-H "X-API-Key: $WHISPER_API_KEY" \
-d '{"query":"MATCH (a:ASN {name:\"AS13335\"})-[:ROUTES]->(p:ANNOUNCED_PREFIX) WHERE p.rpkiStatus = \"invalid\" RETURN p.name AS prefix, p.rpkiInvalidReason AS why, p.roaAsn AS roa_authorized_asn, p.roaMaxLength AS roa_max_length LIMIT 25"}'
Request fields, the response envelope, and how to send a key are on the HTTP API pages. Wire the same queries into an agent via the MCP connector at https://mcp.whisper.security; see AI & Agents. More reusable pivots live in Cross-Layer Patterns; the full edge and property model is in the Graph Schema, and procedure signatures are in Procedures.
Splunk equivalents
For BGP hijack and MOAS detection wired into Splunk dashboards, see Workflows and Splunk Dashboards Reference.