BGP & RPKI

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

Updated July 2026

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. Anonymous access gives you 2 hops and a free key gives you 3 — the deeper physical and RPKI traversals here need 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_PREFIXASN. 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.

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

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

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.

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

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

// Direct BGP neighbours of an ASN
MATCH (a:ASN {name: "AS13335"})-[:BGP_NEIGHBOR]->(peer:ASN)
RETURN peer.name AS peer LIMIT 20

Sample output:

[{"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: tier-1 carriers carry thousands of adjacencies.

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

Prefixes & allocation

ASN prefix inventory

List the prefixes a network is currently announcing.

// All prefixes announced by an ASN
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
RETURN p.name AS prefix LIMIT 20

Sample output:

[
  {"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. (Anonymous 2-hop tier stops short here — use a free key.)

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

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

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

[{"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. In the graph: the isMoas flag is precomputed on every ANNOUNCED_PREFIX, and CONFLICTS_WITH already links the prefix to every conflicting origin AS — the leading early signal of a hijack or route leak.

// Prefixes in an ASN's footprint that are in MOAS conflict, and who else announces them
MATCH (a:ASN {name: "AS13335"})-[: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

Tip. A MOAS isn't proof of a hijack — anycast, multi-homing, and traffic engineering produce legitimate multi-origin announcements. Use CONFLICTS_WITH to enumerate who the other origins are (the announcer itself is excluded), then validate each against RPKI (next section) and CALL explain() for reputation. Multi-origin state shifts as routes change, so any specific example prefix may settle — the query shape is what stays useful.

Confirm a single prefix's MOAS state

Anchor straight on the prefix when you already have it from an alert.

// MOAS / anycast / withdrawn state for a specific prefix
MATCH (a:ASN)-[:ROUTES]->(p:ANNOUNCED_PREFIX {name: "1.1.1.0/24"})
OPTIONAL MATCH (p)-[:CONFLICTS_WITH]->(other:ASN)
RETURN p.name AS prefix, p.isMoas AS is_moas,
       p.isAnycast AS is_anycast, p.isWithdrawn AS withdrawn,
       collect(DISTINCT a.name) AS announcing, collect(DISTINCT other.name) AS conflicts
LIMIT 1

Tip. isAnycast = true with a MOAS is the benign explanation. A MOAS where one origin has a poor explain() verdict and is not RPKI-authorized for the prefix is the one to escalate.

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().

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

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

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

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

// 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. Cross this list against the MOAS conflicts from the previous section: a conflicting origin that holds no valid ROA for the prefix is the high-confidence hijack candidate. An origin with a valid ROA is a legitimate (if surprising) multi-homing relationship.

MOAS + RPKI in one pass

Combine the two: for a network's MOAS prefixes, list the conflicting origins and whether each holds a ROA for that prefix.

// MOAS conflicts cross-checked against RPKI authorization
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
WHERE p.isMoas = true
MATCH (p)-[:CONFLICTS_WITH]->(other:ASN)
WITH p, other LIMIT 50
OPTIONAL MATCH (other)<-[:ROA_AUTHORIZES_ORIGIN]-(roa:ROA)-[:ROA_AUTHORIZES_PREFIX]->(rp:PREFIX)
WHERE rp.name = p.name
RETURN p.name AS prefix, other.name AS conflicting_origin,
       count(roa) > 0 AS roa_authorized
LIMIT 25

Tip. Rows where roa_authorized = false are the ones to wake someone up for. The WITH ... LIMIT 50 bounds the conflict fan-out before the optional ROA join so the query stays fast even on a network with many MOAS prefixes. The Trace a BGP hijack workflow runs this pattern 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.

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

[{"facility": "Equinix DA1 - Dallas"}, {"facility": "Equinix LD5 - London Slough"}]
// 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": "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.

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

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

// 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 via the registered prefix.

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

Sample output:

[{"cloud_region": "aws:us-east-1", "prefixes": 412}, {"cloud_region": "aws:eu-west-1", "prefixes": 188}]

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.

// 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
// Full evidence chain — score, factors, weighted breakdown
CALL explain("AS60729")

Sample output:

[{
  "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, "graphMetricsScore": 65, "historicalScore": 85, "prefixAgeScore": 40}
}]

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.

// All five RIRs
MATCH (rir:RIR) RETURN rir.name AS rir ORDER BY rir.name LIMIT 5

Sample output:

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

Run it from the shell

Every recipe is just a POST. Here's the MOAS 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.isMoas = true MATCH (p)-[:CONFLICTS_WITH]->(o:ASN) RETURN p.name, collect(DISTINCT o.name) LIMIT 25"}'

Request fields, the response envelope, and the auth tiers 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.