Cypher Query Guide

Last updated: March 2026

Quick start

Get an API key

  1. Go to console.whisper.security and create a free account
  2. Once logged in, generate an API key from the console dashboard

Server and authentication

Base URLhttps://graph.whisper.security
AuthenticationX-API-Key header on every request

All examples below assume:

BASE=https://graph.whisper.security
API_KEY=YOUR_API_KEY

Your first query

Resolve a hostname to its IP address:

curl -s -X POST $BASE/api/query \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{"query": "MATCH (h:HOSTNAME {name: \"www.google.com\"})-[:RESOLVES_TO]->(ip:IPV4) RETURN h.name, ip.name"}'

Response:

{
  "columns": ["h.name", "ip.name"],
  "rows": [
    {"h.name": "www.google.com", "ip.name": "142.250.64.100"},
    {"h.name": "www.google.com", "ip.name": "142.251.150.119"},
    {"h.name": "www.google.com", "ip.name": "142.251.151.119"},
    {"h.name": "www.google.com", "ip.name": "142.251.152.119"},
    {"h.name": "www.google.com", "ip.name": "142.251.153.119"},
    {"h.name": "www.google.com", "ip.name": "142.251.154.119"},
    {"h.name": "www.google.com", "ip.name": "142.251.155.119"},
    {"h.name": "www.google.com", "ip.name": "142.251.156.119"},
    {"h.name": "www.google.com", "ip.name": "142.251.157.119"}
  ],
  "statistics": {"rowCount": 9, "executionTimeMs": 6}
}

IP addresses change with DNS updates; the structure is what matters.

Understanding results

Every response has three fields:

FieldContents
columnsColumn names, matching your RETURN clause
rowsArray of objects, one per result row
statisticsrowCount and executionTimeMs (server-side only, no network latency)

API reference

Query endpoint (POST)

Send a Cypher query as JSON.

MethodPOST
URL/api/query
HeadersContent-Type: application/json, X-API-Key: $API_KEY
Body{"query": "CYPHER_STRING", "parameters": {}}

parameters is optional (defaults to {}).

curl -s -X POST $BASE/api/query \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $API_KEY" \
  -d '{"query": "MATCH (n:ASN) RETURN n.name LIMIT 3"}'

Response:

{
  "columns": ["n.name"],
  "rows": [{"n.name": "AS1"}, {"n.name": "AS10"}, {"n.name": "AS100"}],
  "statistics": {"rowCount": 3, "executionTimeMs": 3}
}

Query endpoint (GET)

For quick queries and browser testing -- pass the Cypher string as a query parameter.

MethodGET
URL/api/query?q=CYPHER_STRING
HeadersX-API-Key: $API_KEY
curl -s -H "X-API-Key: $API_KEY" \
  "$BASE/api/query?q=RETURN%201%20%2B%201%20AS%20result"

Stats endpoint

Returns global node and edge counts.

MethodGET
URL/api/query/stats
HeadersX-API-Key: $API_KEY
curl -s -H "X-API-Key: $API_KEY" $BASE/api/query/stats

Response:

{
  "nodeCount": 3600000000,
  "edgeCount": 26000000000,
  "threatIntel": {
    "threatIntelLoaded": true,
    "hasTaxonomy": true,
    "feedSourceCount": 40,
    "categoryCount": 18,
    "totalListedInEdges": 3900000,
    "asnEnrichmentLoaded": true,
    "prefixBgpEnrichmentLoaded": true,
    "available": true
  },
  "timestamp": "2026-01-01T00:00:00.000000000Z"
}

Values are approximate and change with each data refresh.

Error responses

Bad queries come back as HTTP 400 with a structured error body:

{
  "message": "Unexpected token 'INVALID', expected MATCH/RETURN/WITH/UNWIND/CALL at position 0",
  "error": "CypherParseException",
  "timestamp": "2026-01-01T00:00:00.000000000Z"
}

Other error codes you may see:

CodeErrorMeaning
400CypherParseExceptionSyntax error in your query
400CypherExecutionExceptionRuntime error during execution
400CypherExceptionProcedure argument problem (e.g., wrong number of args)
401UnauthorizedMissing or invalid API key
408QueryTimeoutQuery ran too long. Response includes timeoutMs.
429QuotaExceededYou hit your plan's rate limit. Response includes resetAt.
502ExternalApiErrorAn upstream service returned an error
503ExternalApiUnavailableThreat intel backend is temporarily down. Response includes retryAfter.

Graph schema

Node labels

LabelDescriptionExample values
HOSTNAMEFully-qualified hostnamewww.google.com, ns1.google.com
IPV4IPv4 address142.250.64.100, 104.16.123.96
IPV6IPv6 address(AAAA record targets)
PREFIXIP prefix (CIDR block)142.250.64.0/24, 104.16.112.0/20
ASNAutonomous system numberAS1, AS13335, AS15169
ASN_NAMEAutonomous system nameGOOGLE - Google LLC, CLOUDFLARENET - Cloudflare
TLDTop-level domaincom, net, org
CITYCity (GeoIP)Mountain View, US; Sydney, AU
COUNTRYCountry (ISO 2-letter code)US, DE, AU
RIRRegional Internet RegistryAFRINIC, APNIC, ARIN, LACNIC, RIPENCC
ORGANIZATIONOrganization entity (RIR handles + WHOIS registrants)cloudflare, inc.; data protected
TLD_OPERATORTLD registry operatorVeriSign, NISSAN MOTOR CO., LTD.
REGISTRARDomain registrar (WHOIS)iana:292, registrar:markmonitor inc.
EMAILContact email (WHOIS)email:dns-admin@google.com
PHONEContact phone (WHOIS, E.164)+16502530000
DNSSEC_ALGORITHMDNSSEC signing algorithmECDSAP256SHA256, RSASHA256, ED25519
REGISTERED_PREFIXRIR-allocated prefix (virtual)8.8.8.0/24, 1.1.1.0/24
ANNOUNCED_PREFIXBGP-announced prefix (virtual)--
FEED_SOURCEThreat intelligence feed source (virtual)Dan Tor Exit, IPsum, Tranco Top 1M
CATEGORYThreat category classification (virtual)c2, malware, tor, phishing, spam

Edge types

Edge typeSource → TargetDescription
CHILD_OFHOSTNAME, EMAIL → HOSTNAME, TLDDNS hierarchy and email domain association
RESOLVES_TOHOSTNAME → IPV4/IPV6DNS A/AAAA record
BELONGS_TOIPV4, IPV6, PREFIX → PREFIX, RIRIP belongs to prefix; prefix allocated by RIR
NAMESERVER_FORHOSTNAME, TLD → HOSTNAMENS record (nameserver serves domain)
MAIL_FORHOSTNAME, TLD → HOSTNAMEMX record (mail server for domain)
LINKS_TOHOSTNAME → HOSTNAMEWeb hyperlink (Common Crawl data)
ALIAS_OFHOSTNAME → HOSTNAMECNAME record
ROUTESASN → PREFIXASN routes prefix (BGP)
PEERS_WITHASN → ASNBGP peering relationship
SIGNED_WITHHOSTNAME → DNSSEC_ALGORITHMDNSSEC signing (DS records)
SPF_INCLUDEHOSTNAME → HOSTNAME, TLDSPF include mechanism
SPF_IPHOSTNAME → IPV4, PREFIXSPF ip4/ip6 mechanism
SPF_AHOSTNAME → HOSTNAMESPF a: mechanism
SPF_MXHOSTNAME → HOSTNAMESPF mx: mechanism
SPF_EXISTSHOSTNAME → HOSTNAMESPF exists: mechanism
SPF_REDIRECTHOSTNAME → HOSTNAMESPF redirect= modifier
HAS_NAMEASN → ASN_NAMEASN descriptive name
HAS_COUNTRYASN, CITY, IPV4, HOSTNAME, PHONE → COUNTRYCountry association
REGISTERED_BYHOSTNAME, ASN, PREFIX → ORGANIZATIONOrganization registration (WHOIS + RIR)
LOCATED_INIPV4, IPV6 → CITYGeoIP location
OPERATESTLD_OPERATOR → TLDTLD registry operator manages TLD
HAS_REGISTRARHOSTNAME → REGISTRARDomain registered with registrar (WHOIS)
HAS_EMAILHOSTNAME → EMAILDomain contact email (WHOIS)
HAS_PHONEHOSTNAME → PHONEDomain contact phone (WHOIS)
PREV_REGISTRARHOSTNAME → REGISTRARPrevious/historical registrar (WHOIS)
ANNOUNCED_BYIPV4, IPV6 → ANNOUNCED_PREFIXBGP routing (virtual)
LISTED_INIPV4, IPV6, HOSTNAME → FEED_SOURCEThreat indicator listed in feed source (virtual)
CONFLICTS_WITHPREFIX → ASNMOAS conflict (virtual)

Entity relationship map

Entity Relationship MapEntity Relationship Map

Traversal chains

Common multi-hop paths:

ChainExample
DNS ResolutionHOSTNAME → RESOLVES_TO → IPV4 → BELONGS_TO → PREFIX ← ROUTES ← ASN → HAS_NAME → ASN_NAME
GeoIP (from IP)IPV4 → LOCATED_IN → CITY → HAS_COUNTRY → COUNTRY
GeoIP (from hostname)HOSTNAME → RESOLVES_TO → IPV4 → LOCATED_IN → CITY → HAS_COUNTRY → COUNTRY
BGP AnalysisASN → ROUTES → PREFIX → BELONGS_TO → RIR
BGP VirtualIPV4 → ANNOUNCED_BY → ANNOUNCED_PREFIX → ROUTES → ASN
DNS SecurityHOSTNAME ← NAMESERVER_FOR ← HOSTNAME, HOSTNAME → SIGNED_WITH → DNSSEC_ALGORITHM
WHOISHOSTNAME → HAS_REGISTRAR → REGISTRAR, HOSTNAME → HAS_EMAIL → EMAIL
Threat IntelIPV4/HOSTNAME → LISTED_IN → FEED_SOURCE

Cypher language reference

MATCH

MATCH finds patterns in the graph. Property lookups hit an index, so they're fast.

-- Lookup by exact name
MATCH (h:HOSTNAME {name: "www.google.com"}) RETURN h.name
-- Traverse a single relationship
MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
RETURN ip.name
-- Multi-hop traversal: hostname → IP → prefix → ASN → ASN name
MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN h.name, ip.name, p.name, a.name, n.name

Both {name: "value"} inline syntax and WHERE n.name = "value" use the index. Pick whichever reads better.

OPTIONAL MATCH

Like MATCH, but keeps the row and fills in nulls when the pattern doesn't match (instead of dropping the row entirely).

-- Enrich nameservers with their IP addresses
MATCH (h:HOSTNAME {name: "google.com"})<-[:NAMESERVER_FOR]-(ns:HOSTNAME)
OPTIONAL MATCH (ns)-[:RESOLVES_TO]->(ip:IPV4)
RETURN ns.name, ip.name
-- Test for missing data (null padding)
MATCH (h:HOSTNAME {name: "www.google.com"})
OPTIONAL MATCH (h)-[:SIGNED_WITH]->(alg:DNSSEC_ALGORITHM)
RETURN h.name, alg.name

WHERE clause

Comparison operators (=, <>, <, >, <=, >=)

MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
WHERE ip.name <> "0.0.0.0"
RETURN ip.name

String predicates (STARTS WITH, ENDS WITH, CONTAINS)

-- STARTS WITH: fast indexed prefix search
MATCH (h:HOSTNAME) WHERE h.name STARTS WITH "www.google"
RETURN h.name LIMIT 5
-- ENDS WITH: fast for domain suffix patterns (must start with ".")
MATCH (h:HOSTNAME) WHERE h.name ENDS WITH ".google.com"
RETURN h.name LIMIT 5

Suffix search is only fast when the pattern starts with . (e.g., .google.com). Other suffixes trigger a full scan and will be very slow on large labels like HOSTNAME.

-- CONTAINS: full-text search
MATCH (h:HOSTNAME) WHERE h.name CONTAINS "cloudflare"
RETURN h.name LIMIT 5

Regular expressions (=~)

MATCH (h:HOSTNAME)
WHERE h.name =~ "www\.google\.[a-z]{2,3}"
RETURN h.name LIMIT 5

Regex forces a full scan. On large labels like HOSTNAME, that means very slow execution or a timeout. Use STARTS WITH, ENDS WITH, or CONTAINS instead whenever you can.

Logical operators (AND, OR, NOT, XOR)

MATCH (h:HOSTNAME)
WHERE h.name STARTS WITH "www.google" AND NOT h.name ENDS WITH ".com"
RETURN h.name LIMIT 3

Precedence: NOT > AND > XOR > OR. Use parentheses to override.

NULL checks (IS NULL, IS NOT NULL)

MATCH (h:HOSTNAME {name: "www.google.com"})
OPTIONAL MATCH (h)-[:SIGNED_WITH]->(alg:DNSSEC_ALGORITHM)
RETURN h.name, alg IS NULL AS hasNoDNSSEC

List membership (IN)

MATCH (n:COUNTRY)
WHERE n.name IN ["US", "DE", "AU"]
RETURN n.name

IN works best on smaller labels. For large labels like HOSTNAME, use UNWIND with an index lookup instead -- see Batch lookup.

RETURN

You can alias columns, compute values, and call functions in RETURN:

MATCH (h:HOSTNAME {name: "www.google.com"})
RETURN h.name AS hostname, size(h.name) AS nameLength, labels(h) AS nodeLabels

DISTINCT

Remove duplicate rows:

MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:BELONGS_TO]->(p:PREFIX)
RETURN DISTINCT p.name

WITH

WITH pipes results from one stage to the next. Think of it as a checkpoint between MATCHes.

You can also use WITH on its own to define values:

WITH 1 AS x RETURN x
WITH 'hello' AS greeting, 42 AS answer RETURN greeting, answer
-- WITH + UNWIND
WITH [1, 2, 3] AS nums UNWIND nums AS n RETURN n
-- WITH + arithmetic (requires parentheses)
WITH (2 + 3) AS total RETURN total

WITH is also where you do aggregation:

MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(p:PREFIX)
WITH a, count(p) AS cnt
RETURN a.name, cnt

You can also just write consecutive MATCHes without WITH -- the engine reorders them by cheapest anchor cost automatically:

MATCH (a:ASN {name: "AS15169"})-[:HAS_NAME]->(n:ASN_NAME)
MATCH (a)-[:HAS_COUNTRY]->(c:COUNTRY)
RETURN a.name, n.name, c.name

Or use WITH explicitly to bridge two MATCHes:

MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH h, ip
MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)
RETURN h.name, ip.name, p.name

You can chain WITH clauses together:

-- Arithmetic chain
WITH 1 AS x WITH x + 1 AS y RETURN y
-- String concatenation chain
WITH 'hello' AS a WITH a + ' world' AS b RETURN b
-- Triple chain
WITH 1 AS x WITH x + 1 AS y WITH y * 10 AS z RETURN z

When a MATCH constraint references a variable from WITH or UNWIND, the engine does an index lookup at runtime (not a label scan). This is fast -- O(1) per lookup:

-- Parameterized hostname lookup
WITH 'www.google.com' AS hostname
MATCH (h:HOSTNAME {name: hostname})
RETURN h.name
-- With traversal
WITH 'AS13335' AS asnName
MATCH (a:ASN {name: asnName})-[:HAS_NAME]->(n:ASN_NAME)
RETURN a.name, n.name LIMIT 5

ORDER BY

Sort results:

MATCH (n:COUNTRY) RETURN n.name ORDER BY n.name ASC LIMIT 5

Works with aggregation too:

MATCH (c:COUNTRY)<-[:HAS_COUNTRY]-(a:ASN)-[:ROUTES]->(p:PREFIX)
WITH c.name AS country, count(DISTINCT a) AS asn_count
RETURN country, asn_count ORDER BY asn_count DESC LIMIT 5

LIMIT and SKIP

-- SKIP must come before LIMIT
MATCH (n:RIR) RETURN n.name SKIP 2 LIMIT 3

Watch the order: LIMIT N SKIP M is a parse error. Always write SKIP before LIMIT.

UNWIND

Turns a list into rows:

UNWIND [1, 2, 3] AS x
RETURN x, x * 2 AS doubled
-- UNWIND values used as property lookup keys (O(1) index lookup per element)
UNWIND ["AS13335", "AS15169"] AS asn
MATCH (a:ASN {name: asn})
RETURN a.name

Combine with edge traversal:

UNWIND ["AS13335", "AS15169"] AS asn
MATCH (a:ASN {name: asn})-[:HAS_NAME]->(n:ASN_NAME)
RETURN a.name, n.name
-- UNWIND + MATCH with multi-hop traversal
UNWIND ['www.google.com', 'cloudflare.com'] AS h
MATCH (n:HOSTNAME {name: h})-[:RESOLVES_TO]->(ip:IPV4)
RETURN n.name, ip.name

UNION / UNION ALL

Combine results from separate queries. UNION deduplicates; UNION ALL keeps everything.

MATCH (h:HOSTNAME {name: "www.google.com"}) RETURN h.name AS name
UNION
MATCH (h:HOSTNAME {name: "www.cloudflare.com"}) RETURN h.name AS name

All branches must return the same column names.

CALL {} subqueries

Run a nested query for each row of the outer query. The subquery must start with WITH to import variables from the outer scope:

MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
CALL { WITH ip MATCH (ip)-[:BELONGS_TO]->(p:PREFIX) RETURN p }
RETURN ip.name, p.name
-- Multi-hop subquery with full chain
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
CALL { WITH ip MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN) RETURN p, a }
RETURN ip.name, p.name, a.name
-- Subquery with aggregation (each outer row gets independent aggregation)
UNWIND ['www.google.com', 'cloudflare.com'] AS h
CALL { WITH h MATCH (n:HOSTNAME {name: h})-[:RESOLVES_TO]->(ip:IPV4) RETURN count(ip) AS cnt }
RETURN h, cnt

EXPLAIN

Shows the execution plan without running the query:

EXPLAIN MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
RETURN ip.name

Check the plan for NodeLookup or PrefixScan -- if you see a full label scan, your query needs an anchor.

CASE / WHEN

MATCH (h:HOSTNAME {name: "www.google.com"})
RETURN CASE WHEN h.name STARTS WITH "www" THEN "subdomain" ELSE "apex" END AS type
-- Multi-branch CASE
MATCH (h:HOSTNAME {name: "www.google.com"})
RETURN h.name,
  CASE
    WHEN size(h.name) > 10 THEN "long"
    WHEN size(h.name) > 5 THEN "medium"
    ELSE "short"
  END AS length_class

shortestPath()

MATCH p = shortestPath(
  (h:HOSTNAME {name: "www.google.com"})-[*1..5]->(a:ASN {name: "AS15169"})
)
RETURN [n IN nodes(p) | n.name] AS path, length(p) AS hops

At this scale, shortestPath has a limited exploration budget and may not find distant connections. If you already know the traversal pattern, spell it out explicitly -- it's faster and more reliable:

MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN {name: "AS15169"})
RETURN h.name, ip.name, p.name, a.name

Function reference

Aggregation functions

FunctionSyntaxExample
count()count(expr)MATCH (n:ASN) RETURN count(n)
count(*)count(*)MATCH (n:COUNTRY) RETURN count(*)
count(DISTINCT)count(DISTINCT expr)RETURN count(DISTINCT ns)
sumsum(expr)UNWIND [1,2,3] AS x RETURN sum(x)
avgavg(expr)UNWIND [1,2,3] AS x RETURN avg(x)
min / maxmin(expr), max(expr)UNWIND [1,2,3] AS x RETURN min(x)
collectcollect(expr)Collects values into a list
stdev / stdevpstdev(expr)Sample / population standard deviation
percentile_disc / percentile_contpercentile_disc(expr, pct)Discrete / continuous percentile
-- collect() example
MATCH (h:HOSTNAME {name: "google.com"})<-[:NAMESERVER_FOR]-(ns:HOSTNAME)
RETURN collect(ns.name) AS nameservers, count(ns) AS cnt

String functions

FunctionSyntaxDescription
toUppertoUpper(str)Convert to uppercase
toLowertoLower(str)Convert to lowercase
trimtrim(str)Remove leading/trailing whitespace
ltrim / rtrimltrim(str)Remove leading / trailing whitespace
reversereverse(str)Reverse a string
sizesize(str)String length
replacereplace(str, from, to)Substring replacement
substringsubstring(str, start[, len])Extract substring
splitsplit(str, delimiter)Split into list
left / rightleft(str, n)First / last n characters
toStringtoString(val)Convert to string

Numeric functions

FunctionSyntaxDescription
absabs(n)Absolute value
ceil / floorceil(n)Round up / down
roundround(n) or round(n, decimals)Round to nearest / to precision
signsign(n)-1, 0, or 1
sqrtsqrt(n)Square root
log / log10log(n)Natural / base-10 logarithm
expexp(n)Euler's number raised to power
randrand()Random float [0, 1)
e / pie(), pi()Mathematical constants

Trigonometric functions

sin, cos, tan, asin, acos, atan, atan2, degrees, radians -- all work as expected.

Geospatial functions

FunctionSyntaxDescription
pointpoint({latitude: lat, longitude: lon})Create a geographic point
pointpoint(lat, lon)Shorthand form
distancedistance(point1, point2)Haversine distance in meters
-- Distance between San Francisco and New York
RETURN distance(
  point({latitude: 37.7749, longitude: -122.4194}),
  point({latitude: 40.7128, longitude: -74.0060})
) AS meters

Result: 4129086.17 meters (~4,129 km)

Use distance(), not point.distance(). The dotted form doesn't work.

Collection functions

FunctionSyntaxDescription
headhead(list)First element
lastlast(list)Last element
tailtail(list)All elements except first
rangerange(start, end[, step])Integer sequence
coalescecoalesce(v1, v2, ...)First non-null value
isEmptyisEmpty(coll_or_str)Check if empty
propertiesproperties(node)Node metadata (id, label, name)

Node and relationship functions

FunctionSyntaxDescription
idid(node)Internal node ID
labelslabels(node)Node label list
elementIdelementId(node)String-form ID (e.g., "4:whisper:560058031")
typetype(rel)Relationship type name
startNode / endNodestartNode(rel)Source / target node of relationship
nodesnodes(path)Node IDs along a path
relationshipsrelationships(path)Edge types along a path
lengthlength(path)Number of hops in a path

Type conversion functions

FunctionDescription
toInteger(val)Convert to integer
toFloat(val)Convert to float
toBoolean(val)Convert to boolean
toString(val)Convert to string
toIntegerList(list)Convert list elements to integers
toFloatList(list)Convert list elements to floats
toStringList(list)Convert list elements to strings

Date and time functions

FunctionDescriptionExample Result
timestamp()Current epoch milliseconds1771870395517
datetime()Current ISO datetime"2026-02-23T18:13:15Z"
date()Current date"2026-02-23"
randomUUID()Generate a UUID"8677281a-1c58-..."

Pattern and path syntax

Directed relationships

-- Outbound: hostname resolves to IP
MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
RETURN ip.name
-- Inbound: reverse DNS — find hostnames resolving to a specific IP
MATCH (ip:IPV4 {name: "142.250.64.100"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN h.name LIMIT 10
-- Count: how many domains share this IP?
MATCH (ip:IPV4 {name: "104.16.123.96"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN count(h) AS cohosted

Undirected relationships

-- Find any neighbor regardless of edge direction
MATCH (h:HOSTNAME {name: "www.google.com"})--(neighbor)
RETURN labels(neighbor) AS lbl, neighbor.name LIMIT 5

Multi-type relationships

Use | to match multiple edge types at once:

-- Match both nameservers and mail servers
MATCH (h:HOSTNAME {name: "google.com"})<-[:NAMESERVER_FOR|MAIL_FOR]-(s:HOSTNAME)
RETURN s.name LIMIT 10

Variable-length paths

-- Network path: walk from IP through prefix to ASN (1 to 3 hops)
MATCH p = (ip:IPV4 {name: "142.250.64.100"})-[:BELONGS_TO|ROUTES*1..3]->(target)
RETURN [n IN nodes(p) | n.name] AS path, length(p) AS hops

Syntax:

  • [*1..3] -- 1 to 3 hops (recommended)
  • [*..5] -- 0 to 5 hops
  • [*] -- unbounded (always pair with LIMIT or you'll regret it)

Named paths

Assign the whole path to a variable so you can pull it apart:

MATCH p = (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)-[:BELONGS_TO]->(pfx:PREFIX)
RETURN [n IN nodes(p) | n.name] AS path, length(p) AS hops

Anonymous nodes

Skip the variable name for nodes you don't need in RETURN:

MATCH (:HOSTNAME {name: "google.com"})<-[:MAIL_FOR]-(mx:HOSTNAME)
RETURN mx.name

Query cookbook

Incident investigation

Say you've got a flagged IP and need to figure out who owns it, where it is, and what else lives there.

-- Step 1: Identify the network owner
MATCH (ip:IPV4 {name: "142.250.64.100"})-[:BELONGS_TO]->(p:PREFIX)
      <-[:ROUTES]-(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN ip.name, p.name AS prefix, a.name AS asn, n.name AS asnName
-- Step 2: GeoIP lookup
MATCH (ip:IPV4 {name: "142.250.64.100"})-[:LOCATED_IN]->(city:CITY)
      -[:HAS_COUNTRY]->(country:COUNTRY)
RETURN ip.name, city.name AS city, country.name AS country
-- Step 3: How many domains resolve to this IP?
MATCH (ip:IPV4 {name: "104.16.123.96"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN count(h) AS cohostedDomains
-- Step 4: Sample co-hosted domains
MATCH (ip:IPV4 {name: "104.16.123.96"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN h.name LIMIT 10
-- Step 5: Find subdomains via suffix scan
MATCH (h:HOSTNAME) WHERE h.name ENDS WITH ".google.com"
RETURN h.name LIMIT 10
-- Step 6: Check ASN country registration
MATCH (a:ASN {name: "AS15169"})-[:HAS_COUNTRY]->(c:COUNTRY)
RETURN a.name, c.name AS country

GeoIP and geolocation

-- IP to city
MATCH (ip:IPV4 {name: "1.1.1.1"})-[:LOCATED_IN]->(city:CITY)
RETURN ip.name, city.name
-- IP to city to country
MATCH (ip:IPV4 {name: "142.250.64.100"})-[:LOCATED_IN]->(city:CITY)
      -[:HAS_COUNTRY]->(country:COUNTRY)
RETURN ip.name, city.name, country.name
-- Distance calculation
RETURN distance(
  point({latitude: 37.7749, longitude: -122.4194}),
  point({latitude: 40.7128, longitude: -74.0060})
) AS meters

You can also go from hostname all the way to city in one query:

MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:LOCATED_IN]->(city:CITY)
RETURN h.name, ip.name, city.name

Not all IPs have GeoIP data. Anycast addresses like 8.8.8.8 and 9.9.9.9 often lack LOCATED_IN edges. If you need to test, 142.250.x.x and 1.1.1.1 are good bets.

Attack surface discovery

Starting from an ASN, map out everything it routes.

-- Count all routed prefixes
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(p:PREFIX)
RETURN count(p) AS prefixCount
-- ASN name lookup
MATCH (a:ASN {name: "AS13335"})-[:HAS_NAME]->(n:ASN_NAME)
RETURN n.name
-- Find hostnames hosted on an ASN's infrastructure
MATCH (a:ASN {name: "AS13335"})-[:ROUTES]->(p:PREFIX)
      <-[:BELONGS_TO]-(ip:IPV4)<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN h.name LIMIT 10
-- ASN organizational registration
MATCH (a:ASN {name: "AS13335"})-[:REGISTERED_BY]->(org:ORGANIZATION)
RETURN org.name

Blast radius analysis

-- Count BGP peers
MATCH (a:ASN {name: "AS13335"})-[:PEERS_WITH]->(peer:ASN)
RETURN count(peer) AS peerCount
-- Sample affected peers
MATCH (a:ASN {name: "AS13335"})-[:PEERS_WITH]->(peer:ASN)
RETURN peer.name LIMIT 5

Phishing and fraud investigation

You've found a suspicious domain that looks like brand impersonation. The graph lets you map out the full phishing infrastructure: subdomains, shared hosting, nameservers, mail servers, and related domains.

-- Step 1: Find brand-impersonation domains (e.g. "paypal" outside paypal.com)
MATCH (h:HOSTNAME)
WHERE h.name CONTAINS "paypal"
  AND NOT h.name ENDS WITH ".paypal.com"
RETURN h.name LIMIT 15
-- Step 2: Pick a suspect domain and resolve it
MATCH (h:HOSTNAME {name: "paypal-accountlimited-resolutioncenter.netflixdxb.com"})
      -[:RESOLVES_TO]->(ip:IPV4)
RETURN h.name, ip.name
-- Step 3: What else is hosted on the same IPs?
MATCH (h:HOSTNAME {name: "paypal-accountlimited-resolutioncenter.netflixdxb.com"})
      -[:RESOLVES_TO]->(ip:IPV4)<-[:RESOLVES_TO]-(other:HOSTNAME)
WHERE other.name <> h.name
RETURN DISTINCT other.name LIMIT 15
-- Step 4: Map all subdomains under the parent domain
MATCH (h:HOSTNAME) WHERE h.name ENDS WITH ".netflixdxb.com"
RETURN h.name LIMIT 20
-- Step 5: Count subdomains -- a large number suggests a phishing kit
MATCH (h:HOSTNAME) WHERE h.name ENDS WITH ".netflixdxb.com"
RETURN count(h) AS subdomainCount
-- Step 6: Check nameservers -- parking services are a red flag
MATCH (h:HOSTNAME {name: "netflixdxb.com"})<-[:NAMESERVER_FOR]-(ns:HOSTNAME)
RETURN ns.name
-- Step 7: Pivot on nameserver -- what other domains use the same NS?
MATCH (ns:HOSTNAME {name: "ns1.parklogic.com"})-[:NAMESERVER_FOR]->(other:HOSTNAME)
RETURN other.name LIMIT 10
-- Step 8: Check mail infrastructure
MATCH (h:HOSTNAME {name: "netflixdxb.com"})<-[:MAIL_FOR]-(mx:HOSTNAME)
RETURN mx.name
-- Step 9: Network owner of the hosting IP
MATCH (ip:IPV4 {name: "172.233.219.123"})-[:BELONGS_TO]->(p:PREFIX)
      <-[:ROUTES]-(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN p.name AS prefix, a.name AS asn, n.name AS org
-- Step 10: Threat assessment
CALL explain("paypal-accountlimited-resolutioncenter.netflixdxb.com")

IP-first investigation workflow

You've got a suspicious IP from a fraud alert or abuse report. Start from the IP and fan out: threat check, geolocation, network owner, reverse DNS, and neighborhood scan.

-- Step 1: Instant enrichment -- is it flagged, and where is it?
MATCH (ip:IPV4 {name: "185.220.101.1"})
OPTIONAL MATCH (ip)-[:LISTED_IN]->(f:FEED_SOURCE)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(country:COUNTRY)
RETURN ip.name, ip.threatScore, ip.threatLevel,
       collect(DISTINCT f.name) AS feeds,
       city.name AS city, country.name AS country
-- Step 2: Who owns the network?
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:BELONGS_TO]->(p:PREFIX)
      <-[:ROUTES]-(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN p.name AS prefix, a.name AS asn, n.name AS asnName
-- Step 3: Reverse DNS -- what hostnames point here?
MATCH (h:HOSTNAME)-[:RESOLVES_TO]->(ip:IPV4 {name: "185.220.101.1"})
RETURN h.name LIMIT 10
-- Step 4: Neighborhood -- other flagged IPs in the same prefix
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:BELONGS_TO]->(p:PREFIX)
      <-[:BELONGS_TO]-(neighbor:IPV4)
WHERE neighbor.threatLevel IS NOT NULL
RETURN neighbor.name, neighbor.threatScore, neighbor.threatLevel
ORDER BY neighbor.threatScore DESC LIMIT 5
-- Step 5: Detailed threat assessment
CALL explain("185.220.101.1")
-- Step 6: ASN-level threat posture -- is the whole network dirty?
MATCH (a:ASN {name: "AS60729"})
RETURN a.name, a.maxThreatScore, a.avgThreatScore, a.overallThreatLevel

Scam and fraud IP triage

You have a list of IPs from fraud reports, abuse complaints, or scam takedown requests. There's no single "SCAM" category in the threat taxonomy, but scam infrastructure typically shows up across several feed categories: phishing, spam, blacklists, proxies, and scanners. The workflow below shows how to triage a batch of suspect IPs.

-- Step 1: Batch threat check -- triage a list of IPs in one query
UNWIND ["93.174.95.106", "89.248.167.131", "185.220.101.1", "142.250.64.100"] AS addr
MATCH (ip:IPV4 {name: addr})
OPTIONAL MATCH (ip)-[:LISTED_IN]->(f:FEED_SOURCE)
RETURN ip.name, ip.threatScore, ip.threatLevel, collect(f.name) AS feeds
ORDER BY ip.threatScore DESC

Clean IPs come back with null scores and empty feed lists, so you can separate known-bad from unknown in one pass.

-- Step 2: Filter to scam-relevant feeds only
-- Phishing, spam, blacklists, and proxy feeds catch most scam infrastructure
MATCH (ip:IPV4 {name: "93.174.95.106"})-[:LISTED_IN]->(f:FEED_SOURCE)
WHERE f.name IN [
  "OpenPhish Feed", "Blocklist.de Mail", "Blocklist.de All",
  "Spamhaus DROP", "Spamhaus EDROP", "FireHOL Level 1",
  "FireHOL Level 2", "GreenSnow Blacklist", "CINS Score"
]
RETURN f.name
-- Step 3: Infrastructure context -- who, where, what hostname
MATCH (ip:IPV4 {name: "93.174.95.106"})
OPTIONAL MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(country:COUNTRY)
OPTIONAL MATCH (h:HOSTNAME)-[:RESOLVES_TO]->(ip)
RETURN ip.name, ip.threatScore, ip.threatLevel,
       p.name AS prefix, a.name AS asn, n.name AS org,
       city.name AS city, country.name AS country,
       collect(DISTINCT h.name) AS hostnames
-- Step 4: Neighborhood check -- are other IPs in the same /24 also flagged?
MATCH (ip:IPV4 {name: "93.174.95.106"})-[:BELONGS_TO]->(p:PREFIX)
      <-[:BELONGS_TO]-(neighbor:IPV4)
WHERE neighbor.threatLevel IS NOT NULL
RETURN neighbor.name, neighbor.threatScore, neighbor.threatLevel
ORDER BY neighbor.threatScore DESC LIMIT 10

If most of the /24 is flagged, the whole block is likely dirty infrastructure.

-- Step 5: Full assessment for a report
CALL explain("93.174.95.106")

The explain() output gives you feed names, weights, first/last seen timestamps, and a composite score -- everything you need for a fraud report or abuse complaint.

-- Step 6: Batch triage with full context (combine steps 1 + 3)
UNWIND ["93.174.95.106", "89.248.167.131", "185.220.101.1"] AS addr
MATCH (ip:IPV4 {name: addr})
OPTIONAL MATCH (ip)-[:LISTED_IN]->(f:FEED_SOURCE)
OPTIONAL MATCH (ip)-[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(co:COUNTRY)
RETURN ip.name, ip.threatScore, ip.threatLevel,
       collect(DISTINCT f.name) AS feeds,
       p.name AS prefix, a.name AS asn,
       city.name AS city, co.name AS country
ORDER BY ip.threatScore DESC

DNS infrastructure audit

-- Nameserver inventory
MATCH (h:HOSTNAME {name: "google.com"})<-[:NAMESERVER_FOR]-(ns:HOSTNAME)
RETURN ns.name
-- Mail servers
MATCH (h:HOSTNAME {name: "google.com"})<-[:MAIL_FOR]-(mx:HOSTNAME)
RETURN mx.name
-- SPF record analysis
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:SPF_INCLUDE]->(target:HOSTNAME)
RETURN target.name
-- List all DNSSEC algorithms in the graph
MATCH (a:DNSSEC_ALGORITHM)
RETURN a.name

WHOIS investigation

Dig into domain registration and ownership. WHOIS coverage varies by domain -- not all domains have registrar, email, or phone data.

-- Find registrar for a domain
MATCH (h:HOSTNAME {name: "google.com"})-[:HAS_REGISTRAR]->(r:REGISTRAR)
RETURN r.name AS current_registrar
-- Check historical registrars (registrar changes over time)
MATCH (h:HOSTNAME {name: "google.com"})-[:PREV_REGISTRAR]->(r:REGISTRAR)
RETURN r.name AS previous_registrar
-- Find contact emails for a domain
MATCH (h:HOSTNAME {name: "google.com"})-[:HAS_EMAIL]->(e:EMAIL)
RETURN e.name
-- Find contact phone for a domain
MATCH (h:HOSTNAME {name: "google.com"})-[:HAS_PHONE]->(p:PHONE)
RETURN p.name
-- Find registrant organization
MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:REGISTERED_BY]->(o:ORGANIZATION)
RETURN o.name
-- List TLD operators
MATCH (op:TLD_OPERATOR)
RETURN op.name LIMIT 5
-- Cross-reference: find all domains sharing the same registrar
MATCH (h:HOSTNAME {name: "example.com"})-[:HAS_REGISTRAR]->(r:REGISTRAR)
      <-[:HAS_REGISTRAR]-(other:HOSTNAME)
WHERE other.name <> "example.com"
RETURN other.name LIMIT 10

RIR allocation analysis

-- Prefix count by Regional Internet Registry
MATCH (r:RIR)<-[:BELONGS_TO]-(p:PREFIX)
RETURN r.name, count(p) AS prefix_count ORDER BY prefix_count DESC

BGP and routing analysis

-- ASN profile: name and country
MATCH (a:ASN {name: "AS15169"})-[:HAS_NAME]->(n:ASN_NAME)
MATCH (a)-[:HAS_COUNTRY]->(c:COUNTRY)
RETURN a.name, n.name AS asnName, c.name AS country
-- Top countries by ASN count
MATCH (c:COUNTRY)<-[:HAS_COUNTRY]-(a:ASN)-[:ROUTES]->(p:PREFIX)
WITH c.name AS country, count(DISTINCT a) AS asn_count
RETURN country, asn_count ORDER BY asn_count DESC LIMIT 5
-- Find outbound web links
MATCH (h:HOSTNAME {name: "google.com"})-[:LINKS_TO]->(target:HOSTNAME)
RETURN target.name LIMIT 10

CNAME / alias tracking

MATCH (h:HOSTNAME {name: "www.example.com"})-[:ALIAS_OF]->(target:HOSTNAME)
RETURN h.name, target.name

Full investigation chain

Hostname to IP to prefix to ASN to ASN name, in one query:

MATCH (h:HOSTNAME {name: "www.google.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
RETURN h.name, ip.name, p.name, a.name, n.name

Hostname discovery

-- Find subdomains with a prefix
MATCH (h:HOSTNAME) WHERE h.name STARTS WITH "mail.google"
RETURN h.name LIMIT 10
-- Classify hostnames by type
MATCH (h:HOSTNAME {name: "www.google.com"})
RETURN h.name,
  CASE
    WHEN h.name STARTS WITH "www." THEN "web"
    WHEN h.name STARTS WITH "mail." THEN "mail"
    WHEN h.name STARTS WITH "ns" THEN "nameserver"
    ELSE "other"
  END AS hostType

Batch lookup

Got a list of indicators from an alert? UNWIND them and look them all up. Each one hits the index directly (O(1) per item).

-- Batch hostname resolution with IP addresses
UNWIND ['www.google.com', 'cloudflare.com'] AS h
MATCH (n:HOSTNAME {name: h})-[:RESOLVES_TO]->(ip:IPV4)
RETURN n.name, ip.name
-- Batch hostname lookup with per-host IP count
UNWIND ['www.google.com', 'cloudflare.com'] AS h
CALL { WITH h MATCH (n:HOSTNAME {name: h})-[:RESOLVES_TO]->(ip:IPV4) RETURN count(ip) AS cnt }
RETURN h, cnt
-- Parameterized ASN lookup with context
WITH 'AS13335' AS asnName
MATCH (a:ASN {name: asnName})-[:HAS_NAME]->(n:ASN_NAME)
RETURN a.name, n.name

Threat intelligence

The graph is enriched with data from multiple threat feeds across categories including malware, C2, phishing, tor, and more. Millions of IPs and domains are flagged with LISTED_IN edges linking them to their feed sources.

Querying threat feeds

-- Check if an IP is listed in any threat feed
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:LISTED_IN]->(f:FEED_SOURCE)
RETURN f.name, ip.threatScore, ip.threatLevel
-- Check threat category booleans for an IP
MATCH (ip:IPV4 {name: "185.220.101.1"})
RETURN ip.isThreat, ip.isTor, ip.isC2, ip.isMalware, ip.isAnonymizer, ip.threatSources
-- ASN enrichment: check threat posture
MATCH (a:ASN {name: "AS13335"})
RETURN a.maxThreatScore, a.avgThreatScore, a.overallThreatLevel, a.hasThreateningPrefixes
-- PREFIX BGP enrichment: check routing status
MATCH (p:PREFIX {name: "104.16.112.0/20"})
RETURN p.isMoas, p.isAnycast, p.isWithdrawn
-- ASN BGP peers
MATCH (a:ASN {name: "AS13335"})-[:PEERS_WITH]->(n:ASN)
RETURN n.name LIMIT 5

Threat properties

These properties appear on IPV4/IPV6/HOSTNAME nodes when they're listed in at least one feed:

PropertyTypeDescription
threatScoreDoubleComputed threat score (0-100)
threatLevelStringSeverity level (NONE, INFO, LOW, MEDIUM, HIGH, CRITICAL)
threatSourcesLongNumber of feeds listing this indicator
isThreatBooleanListed in any threat feed
isTorBooleanTor exit/relay node
isC2BooleanCommand-and-control infrastructure
isMalwareBooleanMalware distribution
isPhishingBooleanPhishing infrastructure
isAnonymizerBooleanAnonymizer/proxy/VPN
isSpamBooleanSpam source
isBruteforceBooleanBrute-force source
isScannerBooleanPort scanner

ASN nodes get aggregate threat stats:

PropertyTypeDescription
maxThreatScoreDoubleHighest threat score in ASN's prefixes
avgThreatScoreDoubleAverage threat score across prefixes
overallThreatLevelStringASN-level threat classification
hasThreateningPrefixesBooleanAny prefix has threatening IPs

PREFIX nodes carry BGP routing flags:

PropertyTypeDescription
isMoasBooleanMultiple Origin AS (route hijack indicator)
isAnycastBooleanAnycast prefix
isWithdrawnBooleanBGP withdrawal detected

Threat assessment with CALL explain()

explain() gives you a full score breakdown with source attribution. Works for IPs, domains, ASNs, and CIDR prefixes.

-- Explain an IP address (Tor exit node)
CALL explain("185.220.101.1")

Result:

{
  "indicator": "185.220.101.1",
  "type": "ip",
  "found": true,
  "score": 14.48,
  "level": "INFO",
  "explanation": "185.220.101.1 is listed in 5 threat feed(s). Score 14.5 (Informational - minimal risk).",
  "factors": [
    "Listed in 5 source(s) with combined weight 4.50",
    "Base score: 4.50 x log2(5 + 1) = 11.63",
    "Recency boost: x1.2 (last seen just now)",
    "Age boost: x1.04 (on lists for 2 days)",
    "Final score: 11.63 x 1.2 x 1.04 = 14.48"
  ],
  "sources": [
    {"feedId": "dan-tor-exit", "weight": 0.5},
    {"feedId": "firehol-level2", "weight": 1.3},
    {"feedId": "stamparm-ipsum", "weight": 1.2},
    {"feedId": "tor-exit-nodes", "weight": 0.5},
    {"feedId": "greensnow", "weight": 1.0}
  ]
}

Scores and sources change as feeds update. The structure is stable.

-- Explain an ASN (returns reputation score)
CALL explain("AS13335")

Result:

{
  "indicator": "AS13335",
  "type": "asn",
  "found": true,
  "score": 0.0,
  "level": "NONE",
  "explanation": "AS13335 (CLOUDFLARENET - Cloudflare, Inc., US) has a reputation score of 75.0 (REPUTABLE).",
  "factors": [
    "Threat density score: 50.0/100 (weight: 35%)",
    "Graph metrics score: 90.0/100 (weight: 25%)",
    "Historical behavior score: 75.0/100 (weight: 25%)",
    "Prefix age score: 20.0/100 (weight: 15%)",
    "Composite: (50.0x0.35) + (90.0x0.25) + (75.0x0.25) + (20.0x0.15) = 61.8"
  ],
  "breakdown": {
    "threatDensityScore": 50.0,
    "graphMetricsScore": 90.0,
    "historicalScore": 75.0,
    "prefixAgeScore": 20.0
  }
}
-- Explain a CIDR prefix (returns threat density)
CALL explain("1.1.1.0/24")

Result:

{
  "indicator": "1.1.1.0/24",
  "type": "network",
  "found": true,
  "score": 0.0,
  "level": "INFO",
  "explanation": "Network 1.1.1.0/24 contains 1 listed IP(s) and 0 listed subnet(s). Threat density: 0.3906%.",
  "factors": [
    "Listed IPs: 1 IPs found -> 10 x log2(1 + 1) = 10.00",
    "Threat density: 1 listed / 256 addresses = 0.3906%",
    "Final aggregate score: 10.00"
  ]
}
-- Explain a domain
CALL explain("example.com")

What you get back depends on the indicator type:

InputOutputExtra fields
IP address (v4 or v6)Threat feed listings, score with recency boostsources array
Domain nameThreat feed listings, score with recency boostsources array
ASN (e.g., "AS13335")Reputation score with composite breakdownbreakdown object
CIDR prefix (e.g., "1.1.1.0/24")Threat density analysis--

Scoring algorithm

The score formula is logarithmic and weights recent sightings more heavily:

Base score = combined_weight x log2(num_sources + 1)
Recency boost = 1.2 if last seen < 24h, 1.0 if < 7d, 0.8 if < 30d, 0.5 otherwise
Final score = base_score x recency_boost

Score ranges: 0 = NONE, 0-10 = INFO, 10-30 = LOW, 30-60 = MEDIUM, 60-80 = HIGH, 80+ = CRITICAL.


History procedure

whisper.history() pulls historical WHOIS and BGP routing data for a given indicator.

Syntax

CALL whisper.history("indicator")

One argument, always a string literal. The type is auto-detected:

TypePatternExampleBackend
domainValid RFC 1123 hostname (default)google.comDomain WHOIS history
ipIPv4 address8.8.8.8BGP routing history
ip (v6)IPv6 address (2+ colons)2001:4860:4860::8888BGP routing history
networkCIDR prefix8.8.8.0/24BGP routing history
asnAS + digitsAS15169BGP routing history
hashMD5 (32 hex) / SHA-1 (40 hex) / SHA-256 (64 hex)d41d8cd9...Hash lookup

Domain WHOIS history

Returns historical WHOIS snapshots: registrar changes, ownership, nameserver updates.

CALL whisper.history("google.com")

Result (multiple rows):

{
  "columns": ["indicator", "type", "queryTime", "createDate", "updateDate",
              "expiryDate", "registrar", "registrant", "country", "nameServers", "cached"],
  "rows": [
    {
      "indicator": "google.com",
      "type": "domain",
      "queryTime": "2024-11-05 21:15:21",
      "createDate": "1997-09-05",
      "updateDate": "2024-08-02",
      "expiryDate": "2028-09-13",
      "registrar": "MarkMonitor, Inc.",
      "registrant": "Google LLC",
      "country": "US",
      "nameServers": "ns1.google.com|ns2.google.com|ns3.google.com|ns4.google.com",
      "cached": false
    }
  ]
}

Useful for spotting registrar transfers that might indicate domain hijacking or expiration-based takeover.

BGP routing history

Shows which ASNs announced which prefixes and when.

CALL whisper.history("8.8.8.8")

Result (many rows):

{
  "columns": ["indicator", "type", "origin", "prefix", "startTime",
              "endTime", "visibility", "peersSeing", "cached"],
  "rows": [
    {
      "indicator": "8.8.8.8",
      "type": "routing",
      "origin": "AS3356",
      "prefix": "8.0.0.0/8",
      "startTime": "2003-06-01T00:00:00",
      "endTime": "2003-06-12T23:59:59",
      "visibility": 0.0,
      "peersSeing": 41,
      "cached": false
    }
  ]
}

Results cap at 10,000 entries. When truncated, the last row gets truncated: true and totalAvailable: N fields.

This is the main tool for investigating BGP route hijacking -- check who announced a prefix historically and whether the origin ASN changed unexpectedly.

Network and IPv6 history

-- Network prefix history
CALL whisper.history("8.8.8.0/24")

-- IPv6 routing history
CALL whisper.history("2001:4860:4860::8888")

Same output format as IP queries.

Error handling

If the backend is down or times out, you get:

{
  "columns": ["indicator", "type", "available", "error", "retryAfter"],
  "rows": [{"indicator": "AS15169", "type": "asn", "available": false, "error": "timeout", "retryAfter": 30}]
}

Results are cached. If the backend is temporarily unavailable, a circuit breaker returns errors briefly before retrying.


Schema introspection procedures

Query the graph schema itself.

db.labels()

All node labels and their counts (includes virtual labels like FEED_SOURCE and CATEGORY).

CALL db.labels()
ColumnTypeDescription
labelStringNode label name
countLongNumber of nodes with this label

db.relationshipTypes()

All edge types and their counts.

CALL db.relationshipTypes()
ColumnTypeDescription
relationshipTypeStringEdge type name
countLongNumber of edges of this type

db.propertyKeys()

All property keys across all node types.

CALL db.propertyKeys()

db.schema.nodeTypeProperties()

Property metadata per node type -- data types and whether each property is mandatory.

CALL db.schema.nodeTypeProperties()
ColumnTypeDescription
nodeTypeStringNode type (e.g., :HOSTNAME)
nodeLabelsListLabels array
propertyNameStringProperty name
propertyTypesListData type(s)
mandatoryBooleanWhether the property is always present

db.schema.relTypeProperties()

Same thing, but for edge types.

CALL db.schema.relTypeProperties()

db.schema()

Full schema dump. Without arguments you get a Neo4j-style text representation. Pass a format string to change the output:

CALL db.schema()             -- Default: human-readable Cypher-style schema
CALL db.schema("json")       -- Machine-readable JSON (includes nodes, relationships, stats, procedures)
CALL db.schema("markdown")   -- Markdown documentation with tables
CALL db.schema("details")    -- Verbose with descriptions, examples, and property lists

The "json" format is handy for programmatic consumption -- everything in one JSON object.

db.schema.visualization()

Alias for db.schema() with default format.

CALL db.schema.visualization()

explain(indicator)

Threat/reputation assessment. See CALL explain() above for full output format.

CALL explain("185.220.101.1")  -- IP threat assessment
CALL explain("example.com")     -- Domain threat assessment
CALL explain("AS13335")         -- ASN reputation score
CALL explain("1.1.1.0/24")     -- CIDR threat density

whisper.history(indicator)

Historical WHOIS and BGP data. See History procedure above.

CALL whisper.history("google.com")          -- Domain WHOIS history
CALL whisper.history("8.8.8.8")             -- BGP routing history
CALL whisper.history("8.8.8.0/24")          -- Network prefix history
CALL whisper.history("2001:4860:4860::8888") -- IPv6 routing history
CALL whisper.history("AS15169")             -- ASN routing history