Skip to content
Recipes
Skip navigation
Recipes
View as Markdown

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.

Published Last updated

On this page (37)

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. ROUTES is undirected: (asn)-[:ROUTES]->(prefix) matches either arrow. ANNOUNCED_BY goes IP → ANNOUNCED_PREFIX. CONFLICTS_WITH goes ANNOUNCED_PREFIX → ASN, and BGP_PATH goes BGP_PATH_OBSERVATION → ASN. PREFIX_IN_REGION hangs off the allocated PREFIX: anchor on the prefix or on the region, never walk into it from an ASN. The canonical ASN↔ASN adjacency edge is BGP_NEIGHBOR; PEERS_WITH still resolves as an alias, but rename it in saved queries. An ASN's network name lives on a separate ASN_NAME node reached via HAS_NAME (asn.name is the AS number itself), and a ROA has no name: read roa.prefix and roa.maxLength. Never run CONTAINS on ASN.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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"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 coverage before band. 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"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 WITH matters. 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, use CALL 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"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_NEIGHBOR reflects 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:

cypher · runnablegraph.whisper.securitySign in to run
// 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:

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

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[
  {"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_PREFIX is the live BGP view; REGISTERED_PREFIX is 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

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

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"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.1 is), the registration country is the operator's home jurisdiction. The registry itself sits on the block as rp.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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"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. registered is epoch milliseconds. The five registries appear as AFRINIC, APNIC, ARIN, LACNIC and RIPENCC; read the value off the block rather than scanning the RIR label.

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[
  {"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 plain MATCH on 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

Two networks announce one prefix. Which one does RPKI authorize?

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.

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

json
[
  {"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. isMoas is the yes/no; moasIsLegitimate is the triage column, because plenty of prefixes are announced by two networks on purpose (anycast, multi-homing). A null there means the conflict has not been assessed yet: read it as unreviewed, never as "looks fine". CONFLICTS_WITH names 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.

cypher
// 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):

json
[
  {"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,616 CONFLICTS_WITH edges 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"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_status is invalid: the announcement is not covered by the ROA that roa_authorized_asn names. p.rpkiInvalidReason and p.moasIsLegitimate are populated only when they apply, so add them to the RETURN when 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[
  {"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 origin ASNs over time can mean a legitimate IP-space transfer or a historical hijack; cross-reference each origin against the prefix's authorized ROA below. visibility is 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"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 coneAsns and asRank. hijackPostureScore is the graph's own combination of those factors; overallThreatLevel folds 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 carries routeLeakTypes and reputationBulletproofScore if 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.

cypher
// 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):

json
[{"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. maxLength is the RPKI safety net: an origin authorized for 1.1.1.0/24 with maxLength = 24 is not authorized to announce a more-specific 1.1.1.128/25. A more-specific announcement that exceeds maxLength is 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 to authSource = "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.

cypher
// 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):

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

cypher
// 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):

json
[{"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.prefix and roa.maxLength, not roa.name, which is not populated on these nodes and returns a column of nulls. Without the authSource filter the count also takes in routing-registry route objects, which for this network outnumber its RPKI ROAs several times over and carry no maxLength or trustAnchor.

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.

cypher
// 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):

json
[
  {"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_LENGTH means 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_MISMATCH means 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

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

cypher · runnablegraph.whisper.securitySign in to run
// 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:

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

cypher · runnablegraph.whisper.securitySign in to run
// 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:

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

cypher
// 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):

json
[
  {"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 are bulletproof-hosting, critical-infrastructure, ddos-mitigation and satellite-network; anchor on an ASN and OPTIONAL 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[
  {"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; sum asnCount across rows to sanity-check it against the network population. Pair it with whisper.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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"facility": "Equinix SV8 - Silicon Valley, Palo Alto"}, {"facility": "Equinix SV1/SV5/SV10 - Silicon Valley, San Jose"}]
cypher · runnablegraph.whisper.securitySign in to run
// 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:

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

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"facility": "Equinix DA1 - Dallas", "networks_present": 510}]
cypher · runnablegraph.whisper.securitySign in to run
// …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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"shared_facility": "Equinix SV8 - Silicon Valley, Palo Alto"}, {"shared_facility": "Equinix SV1/SV5/SV10 - Silicon Valley, San Jose"}]
cypher · runnablegraph.whisper.securitySign in to run
// 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

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

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"prefix": "108.128.0.0/13", "cloud_region": "aws:eu-west-1"}]

Turn it around to see which networks announce a region's space:

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"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 ROUTES into PREFIX_IN_REGION is 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

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

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{"asn": "AS60729", "level": "LOW", "max_score": 13.2, "avg_score": 6.0, "has_bad_prefixes": true}]
cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[{
  "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 in explanation and breakdown (higher is more trustworthy), and its score and level columns are not the verdict for this indicator type. YIELD the columns above, and read the response's advisories channel, which says the same thing in the payload. For a per-address measure you can compare across networks, CALL whisper.asnThreatDensity("AS60729") returns listedIps, announcedIpv4 and densityRatio.

Tip. An AS with a modest composite but a high threatDensityScore is actively hosting threats even if its history is clean. hasThreateningPrefixes = true is your cue to drill into the individual ANNOUNCED_PREFIX.threatLevel values. 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:

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