Skip to contentSkip navigation

BGP & RPKI

Detect BGP hijacks and MOAS conflicts, check RPKI authorization, and map an ASN's peering and physical footprint with copy-paste Cypher.

On this page (27)

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. WhisperGraph keeps all of it pre-joined — live announcements, BGP adjacency, MOAS conflicts, RPKI ROAs, and the physical layer (which buildings, IXPs, and submarine cables a network actually sits in) — so 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 physical and RPKI traversals here need one; sign in to get a key — there is no card to enter. 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. PREFIX_IN_REGION goes PREFIXCLOUD_REGION and must be matched before you join the announcing ASN onto it, not after. The canonical ASN↔ASN adjacency edge is BGP_NEIGHBOR (PEERS_WITH is an accepted alias of it). An ASN's network name lives on a separate ASN_NAME node reached via HAS_NAME (asn.name is the AS number itself). Never run CONTAINS on ASN.name — it times out; anchor on {name:"AS…"}.

Key concepts: BGP hijacking · MOAS conflict · RPKI ROA · Autonomous system.

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.

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

Sample output:

json
[{"asn": "AS13335", "network_name": "CLOUDFLARENET - Cloudflare, Inc."}]

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. whisper.explain does not return coverage at all. Full contract: Coverage — what we looked at.

ASN scale — prefixes and peers in one shot

Hard with flat tools: prefix count comes from a routing collector, peer count from PeeringDB or a looking glass, and you stitch them yourself. In the graph: both are one hop off the same anchor — and the WITH between the two OPTIONAL MATCHes is load-bearing.

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

Sample output:

json
[{"asn": "AS13335", "prefix_count": 5556, "peer_count": 1304}]

Why the WITH matters. It aggregates prefixes before expanding peers, so you don't multiply 5,556 prefixes by 1,304 peers into a 7M-row Cartesian product. Aggregate, then expand — milliseconds instead of seconds. For the rolled-up threat posture of the AS, use CALL explain("AS13335").

Direct BGP peers

List the ASNs that share a BGP session with a given network.

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

Sample output:

json
[{"peer": "AS31"}, {"peer": "AS49"}, {"peer": "AS112"}, {"peer": "AS1764"}]

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. Count before you list: the largest transit carriers carry thousands of adjacencies.

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": 6525}]

Prefixes & allocation

ASN prefix inventory

List the prefixes a network is currently announcing.

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

Sample output:

json
[
  {"prefix": "1.0.0.0/24"},
  {"prefix": "1.1.1.0/24"},
  {"prefix": "5.11.60.0/23"}
]

Tip. ANNOUNCED_PREFIX is the live BGP view; REGISTERED_PREFIX is the RIR allocation (next recipe). They are often different sizes — one allocation is frequently announced as several more-specifics.

IP → live route → ASN → owner (attribution)

Hard with flat tools: an IP-to-ASN lookup gives you a number; mapping that to the announcing prefix and the registered network name is two more services. In the graph: it's one traversal from the IP through the announced prefix to the AS and its name. This one needs a key.

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

Sample output:

json
[{"ip": "1.1.1.1", "prefix": "1.1.1.0/24", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."}]

IP → registered allocation block & country

The RIR-assigned block (not the BGP announcement), plus the country it was registered in.

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

Sample output:

json
[{"ip": "1.1.1.1", "allocation": "1.1.1.0/24", "country": "AU"}]

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 — confirm anycast behaviour with the isAnycast flag below.

MOAS & hijack detection

BGP hijack detection — a MOAS conflict where a second AS announces a prefix that RPKI authorizes to another origin.

Find the MOAS conflicts in a network's space

Hard with flat tools: detecting a Multi-Origin AS conflict means diffing two collectors' tables and correlating origins by hand. The shape in the graph: a MOAS flag on the ANNOUNCED_PREFIX and a CONFLICTS_WITH edge naming each competing origin AS.

cypher · runnablegraph.whisper.securitySign in to run
// Prefixes in an ASN's footprint that are in MOAS conflict, and who else announces them
MATCH (a:ASN {name: "AS265955"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
WHERE p.isMoas = true
MATCH (p)-[:CONFLICTS_WITH]->(other:ASN)
RETURN p.name AS prefix, p.threatLevel AS prefix_threat,
       collect(DISTINCT other.name) AS conflicting_origins
LIMIT 25

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 15,498 CONFLICTS_WITH edges against 1.4 billion announced prefixes, and a well-run network has none. Cloudflare's AS13335 routes zero MOAS prefixes today, which is why the seed above is a network that does have one. An empty result means this network is not in conflict, not we have no data. To find conflicts you do not already know about, start from the prefix side — MATCH (p:ANNOUNCED_PREFIX)-[:CONFLICTS_WITH]->(o:ASN) — rather than from an ASN you picked first.

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.

cypher · runnablegraph.whisper.securitySign in to run
// Origin, anycast 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.anycastConfirmed AS is_anycast, p.anycastConfidence AS anycast_confidence,
       p.rpkiStatus AS rpki_status, p.roaAsn AS roa_authorized_asn
LIMIT 1

Sample output:

json
[{"prefix": "1.1.1.0/24", "announcing_origins": ["AS13335"], "is_anycast": true,
  "anycast_confidence": 1.0, "rpki_status": "valid", "roa_authorized_asn": 13335}]

Tip. is_anycast = true is the benign explanation for a prefix that looks like it is announced from many places at once. The row to escalate is one where rpki_status is invalid — the announcement is not covered by the ROA that roa_authorized_asn names — and the announcing origin also has a poor explain() verdict.

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. Requires an API key — full signature on whisper.history().

cypher · runnablegraph.whisper.securitySign in to run
// BGP origin history for a prefix
CALL whisper.history("1.1.1.0/24")
YIELD origin, prefix, visibility
RETURN origin, prefix, visibility
LIMIT 10

Sample output:

json
[
  {"origin": "AS226", "prefix": "1.1.1.0/24", "visibility": 0.0},
  {"origin": "AS237", "prefix": "1.0.0.0/8", "visibility": 0.0}
]

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. The same call works on an ASN or an IP, and history over a large network can take many seconds, so keep the LIMIT and expect a longer round trip.

RPKI ROA coverage

Is this origin authorized to announce this prefix?

Hard with flat tools: you run a separate RPKI validator, then manually reconcile its answer against the live origin. 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 — one traversal tells you both.

cypher · runnablegraph.whisper.securitySign in to run
// ROAs covering a prefix, and the origin AS each authorizes
MATCH (roa:ROA)-[:ROA_AUTHORIZES_PREFIX]->(p:PREFIX {name: "1.1.1.0/24"})
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.validFrom AS valid_from, roa.validUntil AS valid_until
LIMIT 10

Sample output:

json
[{"prefix": "1.1.1.0/24", "authorized_origin": "AS13335", "max_length": 24, "trust_anchor": "apnic"}]

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.

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

cypher · runnablegraph.whisper.securitySign in to run
// All prefixes an ASN's ROAs authorize it to originate
MATCH (a:ASN {name: "AS13335"})
MATCH (roa:ROA)-[:ROA_AUTHORIZES_ORIGIN]->(a)
MATCH (roa)-[:ROA_AUTHORIZES_PREFIX]->(p:PREFIX)
RETURN a.name AS asn, p.name AS authorized_prefix,
       roa.maxLength AS max_length, roa.trustAnchor AS trust_anchor
LIMIT 25

Tip. This is the "who should be announcing this" side. Cross it against what is actually announced — the next recipe does exactly that, and the prefixes where the two disagree are the hijack candidates.

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.

cypher · runnablegraph.whisper.securitySign in to run
// Announcements a network makes that RPKI marks invalid
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
WHERE p.rpkiStatus = "invalid"
RETURN p.name AS prefix, p.rpkiStatus AS rpki_status,
       p.roaAsn AS roa_authorized_asn, p.roaMaxLength AS roa_max_length
LIMIT 25

Sample output:

json
[
  {"prefix": "162.158.208.0/24", "rpki_status": "invalid", "roa_authorized_asn": 13335, "roa_max_length": 22},
  {"prefix": "103.21.244.0/24", "rpki_status": "invalid", "roa_authorized_asn": 0, "roa_max_length": 23}
]

Tip. Read the two invalid cases apart. roa_authorized_asn matching the announcing AS, with a roa_max_length shorter than the announced prefix length, is the maxLength case above: the right origin, too specific an announcement. roa_authorized_asn: 0 means no ROA authorizes this origin for the space at all — that is the one to wake someone up for. The Trace a BGP hijack workflow runs this check and then walks the exposed hostnames for you.

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?

Hard with flat tools: PeeringDB facility/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 (IXPs) 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

Sample output:

json
[{"facility": "Equinix DA1 - Dallas"}, {"facility": "Equinix LD5 - London Slough"}]
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": "LINX LON1", "hosted_in": ["Telehouse North - London", "Equinix LD8 - London"]}]

Tip. Facility and IX overlap is how you reason about shared-fate and physical blast radius: two ASNs that meet only at a single IXP have a very different risk profile from two that share a cage in the same building.

Who else lives in this building? (shared-fate)

Pivot from a facility to every network present in it — a physical co-tenancy view that no flat lookup gives you.

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

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

Where do two networks physically meet?

Find the facilities or exchanges two ASNs have in common — useful for diagnosing why a "peering" relationship exists, or for mapping concentration risk.

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

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

Tip. Two ASNs that are BGP_NEIGHBORs and share an IXP 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.

Submarine cables behind a landing region

Subsea cables are the deepest physical layer — SUBMARINE_CABLECABLE_LANDS_ATCABLE_LANDING, with LANDING_NEAR tying a landing point to a nearby facility.

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

Tip. Chain LANDING_NEARFACILITYAS_PRESENT_AT to reason about which networks sit closest to a cable landing — the kind of concentration analysis that matters when a single cable cut can degrade a region. For the full dependency-mapping angle, see Infrastructure & Supply Chain.

Prefix → cloud region

Map a network's announced space to the cloud regions it lands in. Match the region edge first, then join the ASN onto itPREFIX_IN_REGION is synthesized, and it does not expand off a prefix you arrived at by walking ROUTES.

cypher · runnablegraph.whisper.securitySign in to run
// Cloud regions a network's prefixes sit in
MATCH (p:PREFIX)-[:PREFIX_IN_REGION]->(r:CLOUD_REGION)
MATCH (a:ASN {name: "AS16509"})-[:ROUTES]->(p)
RETURN r.name AS cloud_region, count(DISTINCT p) AS prefixes
ORDER BY prefixes DESC LIMIT 25

Sample output — 602 ms server:

json
[
  {"cloud_region": "aws:us-west-2", "prefixes": 213},
  {"cloud_region": "aws:eu-central-1", "prefixes": 141},
  {"cloud_region": "aws:ap-northeast-1", "prefixes": 137},
  {"cloud_region": "aws:us-east-1", "prefixes": 133}
]

Cloud-region coverage is partial. A zero-row result means Whisper has not mapped that network's prefixes to a tracked region. It never means the network has no cloud presence.

Tip. Write it the other way round — (a:ASN)-[:ROUTES]->(p:PREFIX)-[:PREFIX_IN_REGION]->(r) — and you get a clean HTTP 200 with zero rows, which reads like "this network has no cloud presence" rather than "you composed the traversal in the wrong order". Anchor the synthesized edge, then join.

Reputation & history

Roll up an ASN's threat posture

Don't walk ASN → prefix → IP → LISTED_IN by hand on a large network — it times out. The reconciled rollup is precomputed on the ASN node, and explain() returns the inspectable evidence chain.

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

cypher · runnablegraph.whisper.securitySign in to run
// Full evidence chain — explanation, factors, weighted breakdown
CALL explain("AS60729")
YIELD indicator, type, found, explanation, factors, breakdown
RETURN indicator, type, found, explanation, factors, breakdown

Sample output (factors elided):

json
[{
  "indicator": "AS60729",
  "type": "asn",
  "found": true,
  "explanation": "AS60729 (TORSERVERS-NET - Stiftung Erneuerbare Freiheit, DE) has a reputation score of 25.0 (HIGHLY_SUSPICIOUS). This ASN shows suspicious characteristics.",
  "breakdown": {"threatDensityScore": 30.0, "graphMetricsScore": 65.0, "historicalScore": 85.0,
                "prefixAgeScore": 20.0, "graphListedIps": 156, "graphAnnouncedIpv4": 768,
                "graphDensityRatio": 0.203125, "graphCoverage": "computed"}
}]

Name your columns. A bare CALL explain(x) returns all eleven columns, and advisory is populated only for a few indicators — on most seeds it comes back null, which reads as missing data rather than "no advisory applies". YIELD the columns the question actually needs.

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.

The five regional registries

A quick reference scan — RIR is small enough to list directly.

cypher · runnablegraph.whisper.securitySign in to run
// All five RIRs
MATCH (rir:RIR) RETURN rir.name AS rir ORDER BY rir.name LIMIT 5

Sample output:

json
[{"rir": "AFRINIC"}, {"rir": "APNIC"}, {"rir": "ARIN"}, {"rir": "LACNIC"}, {"rir": "RIPENCC"}]

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.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 Cypher 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 Splunk Use Cases and Splunk Dashboards Reference.