# BGP & RPKI

> Cypher for BGP and RPKI on WhisperGraph: MOAS detection, ROA checks, hijack and route-leak posture, AS paths, cloud regions and SaaS egress ranges.

*Source: https://www.whisper.security/docs/recipes/bgp-routing*
*Published: 2026-05-04*
*Last updated: 2026-10-06*

---
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](/products/intelligence/use-cases/network-routing/route-health) · [Enrich an ASN](/products/intelligence/use-cases/network-routing/route-health) · [Trace a BGP hijack to its exposed domains](/products/intelligence/use-cases/network-routing/bgp-hijack-exposure) · [Audit an ASN's routing hygiene](/products/intelligence/use-cases/network-routing/bgp-hijack-exposure) — 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](/docs/workflows#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](https://console.whisper.security/sign-in?redirect_url=https%3A%2F%2Fwww.whisper.security%2Fdocs%2Frecipes%2Fbgp-routing) to get a key. New to the surface? Start with [Getting Started](/docs/whisper-graph/getting-started), the [Graph Schema](/docs/whisper-graph/schema), and [Procedures](/docs/whisper-graph/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](/glossary/bgp-hijacking) · [MOAS conflict](/glossary/moas-conflict) · [RPKI ROA](/glossary/rpki-roa) · [Route origin validation](/glossary/route-origin-validation) · [Autonomous system](/glossary/autonomous-system) · [Internet exchange point](/glossary/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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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](#asn-scale-prefixes-and-peers-in-one-shot).

> **Read `coverage` before `band`.** Only `known-clean` licenses the word "clean"; `no-data` means
> *unknown*, which is a different thing again; `malicious-evidenced` and `ambiguous` mean there is
> evidence, whatever the band says.
> Full contract: [Coverage — what we looked at](/docs/whisper-graph/procedures/coverage).

### 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 MATCH`es decides whether the query returns in milliseconds or not at all.

```cypher expect=rows>0 seed=AS13335 verified=2026-09-02
// 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).

### 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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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 expect=rows>0 seed=AS3356 verified=2026-09-02
// 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?](#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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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).

### 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 expect=rows>0 seed=1.1.1.1 verified=2026-09-02
// 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).

### 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 expect=rows>0 seed=1.1.1.1 verified=2026-09-02
// 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).

### 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 expect=rows>0,no-null-columns seed=1.1.1.0/24 verified=2026-09-02
// 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).

### 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 expect=rows>0 seed=1.0.0.0/24 verified=2026-09-02
// 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?](#which-prefixes-are-in-conflict-right-now).

## MOAS & hijack detection

![Diagram: ASN AS64500 ROUTES the ANNOUNCED_PREFIX 203.0.113.0/24, whose isMoas is true. The prefix CONFLICTS_WITH a second origin, ASN AS64511. A ROA whose prefix is 203.0.113.0/24 and whose authSource is rpki-roa, a signed RPKI record, ROA_AUTHORIZES_ORIGIN AS64500, and the prefix's roaAsn names the same network, so no ROA authorizes the competing origin. Documentation-range values.](/media/df4QIu6ICVtldSXM_whisper-moas-rpki.svg "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 expect=static verified=2026-09-02 reason="multi-origin conflict state changes as prefixes are withdrawn and re-announced, so this block shows a captured result rather than a live run"
// 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).

### 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 expect=static seed=AS36014 verified=2026-09-03 reason="multi-origin conflict state changes as prefixes are withdrawn and re-announced, so this block shows a captured result rather than a live run"
// 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 3,829 `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).

### 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 expect=rows>0,no-null-columns seed=1.1.1.0/24 verified=2026-09-02
// 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).

### 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()](/docs/whisper-graph/procedures/history). Sign in to run it.

```cypher expect=rows>0 seed=1.1.1.0/24 verified=2026-09-02
// 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).

### 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 expect=rows>0,no-null-columns seed=AS13335 verified=2026-09-02
// 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?](#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 expect=static seed=1.1.1.0/24 verified=2026-10-06 reason="RPKI authorization data is not always present on a read, so this block shows a captured result rather than a live run"
// 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).

### 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 expect=static seed=AS13335 verified=2026-10-06 reason="RPKI authorization data is not always present on a read, so this block shows a captured result rather than a live run"
// 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 expect=static seed=AS13335 verified=2026-10-06 reason="RPKI authorization data is not always present on a read, so this block shows a captured result rather than a live run"
// 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).

### 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 expect=static seed=AS13335 verified=2026-10-06 reason="RPKI authorization data is not always present on a read, so this block shows a captured result rather than a live run"
// 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](/products/intelligence/use-cases/network-routing/bgp-hijack-exposure) workflow runs this check and then walks the exposed hostnames for you.

**From here, →** [Which AS paths transit this network?](#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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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).

### 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 expect=rows>0 seed=AS201814 verified=2026-09-02
// 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](#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).

### 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 expect=static verified=2026-09-03 reason="signal assignments are recomputed as the underlying evidence changes, so this block shows a captured result rather than a live run"
// 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).

### 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 expect=rows>0 verified=2026-09-02
// 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?](#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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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).

### 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 expect=rows>0 verified=2026-09-02
// 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 expect=rows>0 verified=2026-09-02
// …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).

### 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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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 expect=rows>0 seed=AS13335 verified=2026-09-02
// 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_NEIGHBOR`s *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).

### 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 expect=rows>0 seed=2Africa verified=2026-09-02
// 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](/docs/workflows#infrastructure-supply-chain).

**From here, →** [Prefix → cloud region](#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 expect=rows>0 seed=108.128.0.0/13 verified=2026-09-02
// 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 expect=rows>0 seed=aws:eu-west-1 verified=2026-09-02
// 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).

### 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 expect=rows>0 seed=zoom verified=2026-09-02
// 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](#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()](/docs/whisper-graph/procedures/explain) returns the inspectable reasoning behind it.

```cypher expect=rows>0 seed=AS60729 verified=2026-09-28
// 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 expect=rows>0,no-null-columns seed=AS60729 verified=2026-09-28
// 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?](#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 expect=rows>0,no-null-columns seed=AS13335 verified=2026-09-02
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](/docs/cypher-api) pages. Wire the same queries into an agent via the MCP connector at `https://mcp.whisper.security`; see [AI & Agents](/docs/ai). More reusable pivots live in [Cross-Layer Patterns](/docs/recipes/cross-cutting); the full edge and property model is in the [Graph Schema](/docs/whisper-graph/schema), and procedure signatures are in [Procedures](/docs/whisper-graph/procedures).

## Splunk equivalents

For BGP hijack and MOAS detection wired into Splunk dashboards, see [Workflows](/docs/workflows) and [Splunk Dashboards Reference](/docs/integrations/splunk/dashboards).
