Cross-Layer Patterns
The patterns that compose attribution, verdicts, blast radius, history, and identity into one sourced investigation, plus the batch and automation shapes that keep repetitive jobs fast.
On this page (27)
- Recipe 1 — Paste one domain, get a sourced report
- Recipe 2 — The full verdict with its evidence chain
- Recipe 3 — Blast radius from one indicator to the campaign
- Recipe 4 — What it was before: WHOIS + BGP history
- Recipe 5 — Identity is not a verdict (gate on coverage)
- Recipe 6 — The full investigation in one query
- Recipe 7 — Cross-persona pivot: domain → typosquats → verdict → owner
- Recipe 8 — Routing-layer cross-check: hijack signal + RPKI + owner
- Recipe 9 — Physical + logical footprint of a network
- Recipe 10 — Run it autonomously over MCP
- From a submarine cable to the clicks it carries
- Working in batches
- How do I get a verdict with coverage for a whole watchlist?
- How do I get owner, network, country and band for a mixed list?
- How do I score a list of indicators in one round trip?
- How do I pull registration history for a list of domains?
- How do I list subdomains for several apexes without one starving the rest?
- Which SaaS vendor owns each of these egress addresses?
- Which of these source IPs are Tor, VPN or proxy infrastructure?
- How many ROAs authorize each network in a list?
- What kind of thing is each token in an unlabelled list?
- Built for automation
- How do I query history from a scheduled job without the columns shifting?
- What routing history do I get for an IP, with stable columns?
- What is the response telling me outside the rows?
- Can I pass a URL instead of a host?
- Where to go next
Cross-Layer Patterns Documentation
This is where the use cases converge. A single indicator — a domain in a phishing email, an IP in a firewall log, an ASN in a routing alert — rarely tells you enough on its own. These recipes take you to the composed answer: attribution (whose network), the verdict (is it bad, and who says so), blast radius (what else moves with it), history (what it was before), and identity (what kind of host it is) in one sourced report — and then to the batch and automation shapes that run the same answer over a whole list, or from a scheduled job, without the columns shifting. Flat tools make you run that as a dozen disconnected lookups and stitch the results by hand. On a pre-joined graph it is a handful of anchored traversals, and an AI agent can run the whole sequence over MCP without you touching a keyboard.
Every pattern below is copy-paste against https://graph.whisper.security/api/query. See Getting Started for the key, the Graph Schema for the full model, and Procedures for the CALL signatures.
Key concepts: Blast radius · Reconciled verdict · Coverage-qualified assessment · MOAS conflict · MCP.
Recipe 1 — Paste one domain, get a sourced report
Why it's hard with flat tools: answering "what is paypal.com, who runs it, is it clean, and what's its registrar posture?" is a WHOIS lookup, a DNS lookup, an ASN lookup, a GeoIP lookup, and a reputation lookup — five tools, five formats, and you reconcile them in a spreadsheet.
What the graph does: anchor once on the hostname and fan out across DNS, routing, geo, registrar, and the threat posture in a single statement. Every value is a graph edge you can cite.
// One domain → resolution, network owner, country, registrar, threat posture
MATCH (h:HOSTNAME {name: "paypal.com"})-[:RESOLVES_TO]->(ip:IPV4)
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(co:COUNTRY)
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(reg:REGISTRAR)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network,
city.name AS city, co.name AS country, reg.name AS registrar,
ip.isThreat AS isThreat, ip.threatScore AS threatScore
LIMIT 5
Tip: Keep the routing, name, and geo legs as
OPTIONAL MATCH— anycast and CDN IPs frequently lack a city, and optional matches return the row anyway instead of dropping it. The threat properties (threatScore,threatLevel,isThreat,isTor,isAnonymizer) live on the node itself. ReturnisThreatandthreatScorefor the posture: both are always set —falseand0.0on an address no feed has listed.threatLevelis only written once a feed lists the address, so on a clean one the column comes back empty, which reads like missing data when it is actually the verdict. When the list is longer than one,whisper.enrichdoes this join server-side for every indicator at once — see Working in batches.
Recipe 2 — The full verdict with its evidence chain
Why it's hard with flat tools: a reputation score with no provenance is a number you can't defend in a ticket. "Why is this 90?" gets a shrug.
What the graph does: explain() auto-detects the indicator type and returns the score and the arithmetic behind it — every contributing feed, its weight, and its first/last-seen. The factors and sources arrays are an inspectable evidence chain you can paste straight into a ticket, and the same call works on a domain, IP, ASN, or CIDR.
// Scored verdict + the exact feeds and factors behind it
CALL explain("185.220.101.1")
YIELD indicator, type, found, score, level, explanation, factors, sources
RETURN indicator, type, found, score, level, explanation, factors, sources
A run of that on 2026-08-09 returned:
[{
"indicator": "185.220.101.1",
"type": "ip",
"found": true,
"score": 21.440094319478078,
"level": "LOW",
"explanation": "185.220.101.1 is listed in 6 threat feed(s). Score 21.4 (Low - limited risk).",
"factors": [
"Listed in 6 source(s) with combined weight 6.00",
"Base score: 6.00 × log₂(6 + 1) = 16.84",
"Recency boost: ×1.2 (last seen 19 hours ago)",
"Age boost: ×1.06 (on lists for 5 days)",
"Final score: 16.84 × 1.2 × 1.06 = 21.44"
],
"sources": [
{"feedId": "tor-exit-nodes", "weight": 0.5, "firstSeen": "2026-08-03T16:21:48.400511263Z", "lastSeen": "2026-08-08T10:06:47.505353314Z"},
{"feedId": "firehol-abusers-1d", "weight": 1.5, "firstSeen": "2026-08-03T16:21:20.771305802Z", "lastSeen": "2026-08-07T07:51:07.776194553Z"},
{"feedId": "greensnow", "weight": 1.0, "firstSeen": "2026-08-03T16:22:08.761536178Z", "lastSeen": "2026-08-06T07:38:10.889494390Z"},
{"feedId": "firehol-level2", "weight": 1.3, "firstSeen": "2026-08-04T17:38:44.217845576Z", "lastSeen": "2026-08-04T17:38:44.217845576Z"},
{"feedId": "stopforumspam-listed-ip-7d", "weight": 0.5, "firstSeen": "2026-08-03T16:22:10.328361154Z", "lastSeen": "2026-08-06T07:38:35.857131131Z"},
{"feedId": "stamparm-ipsum", "weight": 1.2, "firstSeen": "2026-08-03T16:22:08.606554919Z", "lastSeen": "2026-08-08T23:45:58.114818276Z"}
]
}]
Tip: Paste
factorsandsourcesstraight into the case notes — they're the citation. The verdict reflects whichever feeds are currently loaded, so treat the score as a live read, not a fixed number. Name the columns you want inYIELD: a bareCALL explain(...)also hands back anadvisorycolumn that only some indicators carry (explain("1.1.1.1")returnsallowlist-vouched; most indicators return nothing there), and an empty column in the middle of a report invites the wrong conclusion.YIELD *is rejected onexplain, because the emitted columns depend on the indicator type.CALL explain("AS13335")andCALL explain("185.220.101.0/24")work the same way for an ASN or a CIDR range. To keep only the feeds that drove the verdict,UNWIND sources AS sand filterWHERE s.weight >= 1.0.
Recipe 3 — Blast radius from one indicator to the campaign
Why it's hard with flat tools: you've got one bad domain. The follow-up question — what else moves with it? — means pivoting on shared IP, shared registrant email, and shared nameserver, each a separate query against a separate index.
What the graph does: co-tenancy, shared-registrant, and shared-infrastructure pivots are all one hop away from the same anchor. This finds every sibling domain on the same IP and every domain sharing the WHOIS contact email, in one round-trip.
// One domain → co-tenant siblings + shared-registrant domains
MATCH (h:HOSTNAME {name: "paypal.com"})-[:RESOLVES_TO]->(ip:IPV4)
MATCH (ip)<-[:RESOLVES_TO]-(sibling:HOSTNAME)
WHERE sibling.name <> h.name
WITH h, collect(DISTINCT sibling.name)[..15] AS co_tenants
OPTIONAL MATCH (h)-[:HAS_EMAIL]->(e:EMAIL)<-[:HAS_EMAIL]-(shared:HOSTNAME)
WHERE shared.name <> h.name
RETURN co_tenants, collect(DISTINCT shared.name)[..15] AS shared_registrant
LIMIT 1
Tip: Co-tenancy on a big shared host or CDN IP is high-fan-out by design — the
collect(...)[..15]bound keeps it fast and honest. Shared registrant email is the higher-signal pivot: it ties a domain to its operator across IP and registrar changes. The same shape works onHAS_REGISTRARandNAMESERVER_FOR. Raw registrant organisation strings vary in spelling;(:ORGANIZATION)-[:SAME_ORG_AS]->(:ORGANIZATION)folds them to one canonical company.
Recipe 4 — What it was before: WHOIS + BGP history
Why it's hard with flat tools: a current snapshot hides the tell. A domain that changed registrar last week, or a prefix whose origin AS flipped, is the lead — and you can't see it without a time machine.
What the graph does: the history procedures return timestamped snapshots — whisper.history.whois() the WHOIS history for a domain (create/update dates, registrar, registrant, nameservers), whisper.history.bgp() the routing history for an IP/ASN/prefix (which network announced a block, and when). One call replaces a passive-DNS subscription and a BGP archive.
// Registrar + nameserver history for a domain (needs an API key)
CALL whisper.history.whois("paypal.com")
YIELD createDate, updateDate, registrar, registrant, nameServers
RETURN createDate, updateDate, registrar, registrant, nameServers
LIMIT 5
You can also read the current registrar transition straight off the edges — HAS_REGISTRAR is the current registrar, PREV_REGISTRAR any prior one:
// Current vs. prior registrar in one shot
MATCH (h:HOSTNAME {name: "paypal.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(cur:REGISTRAR)
OPTIONAL MATCH (h)-[:PREV_REGISTRAR]->(prev:REGISTRAR)
RETURN h.name AS domain, cur.name AS current_registrar,
collect(DISTINCT prev.name) AS prior_registrars
LIMIT 1
Tip: The history procedures are the one composite-report ingredient that needs a key, so sign in to run them — there is no card to enter. Each single-shape variant emits a fixed column set, which is what makes them safe to schedule (Built for automation). BGP history over a large network can take many seconds: keep a
LIMITon it and expect a longer round trip than an anchored read.
Recipe 5 — Identity is not a verdict (gate on coverage)
Why it's hard with flat tools: "this host is on AWS, and AWS hosts malware, so this host is suspicious" is the false-positive engine. Whose infrastructure something is and whether it's dangerous are different questions — most tools blur them.
What the graph does: two procedures answer them separately. whisper.identify() tells you what the host is — the vendor behind it, its category, and its tenancy class; whisper.assess() gives a verdict qualified by coverage, so "no data" reads as "unknown," never "benign."
// Whose infrastructure is this host?
CALL whisper.identify(["github.com"])
YIELD host, vendor_id, canonical_name, category, roles, host_class, band
RETURN host, vendor_id, canonical_name, category, roles, host_class, band
LIMIT 5
// Is it dangerous — and how much do we actually know?
CALL whisper.assess(["github.com"])
YIELD host, label, band, coverage, evidence
RETURN host, label, band, coverage, evidence
LIMIT 5
Read
coveragebeforeband. Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean"; no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. means unknown, which is a different thing again; malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. and ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. mean there is evidence, whatever the band says.whisper.explaindoes not returncoverageat all. Full contract: Coverage — what we looked at.
Tip: Read
host_classtoo:multi_tenant_user_contentmeans anyone can publish there, so one bad URL doesn't condemn the domain. Whenidentifyfinds no direct match,CALL whisper.walk("example.com")returns the bounded structural neighborhood instead. Both procedures also accept a single host string, and a full URL folds down to its host — see Can I pass a URL instead of a host?.
Recipe 6 — The full investigation in one query
Why it's hard with flat tools: the report your lead actually wants — attribution, verdict, registrant, geo, mail posture — is a half-day of tab-switching, and the joins live only in your head.
What the graph does: because every layer shares one anchor, the whole report is one traversal. This is the paste-one-indicator → sourced-report flow, end to end.
// Composite report: owner + geo + registrar + registrant + mail + threat
MATCH (h:HOSTNAME {name: "paypal.com"})
OPTIONAL MATCH (h)-[:RESOLVES_TO]->(ip:IPV4)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(co:COUNTRY)
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(reg:REGISTRAR)
OPTIONAL MATCH (h)-[:REGISTERED_BY]->(org:ORGANIZATION)
OPTIONAL MATCH (h)<-[:MAIL_FOR]-(mx:HOSTNAME)
WITH h, ip, ap, n, co, reg, org, collect(DISTINCT mx.name)[..5] AS mail_servers
RETURN h.name AS domain, ip.name AS ip, ap.name AS prefix,
n.name AS network, co.name AS country, reg.name AS registrar,
org.name AS registrant, mail_servers,
ip.isThreat AS isThreat, ip.threatScore AS threatScore
LIMIT 5
Tip:
MAIL_FORpoints server → domain, so a domain's mail servers are reached backwards:(domain)<-[:MAIL_FOR]-(mx). The same direction trap applies toNAMESERVER_FOR. Wrap the mail leg in its owncollect(...)[..5]so a domain with many MX records doesn't multiply every other row.
Recipe 7 — Cross-persona pivot: domain → typosquats → verdict → owner
Why it's hard with flat tools: brand protection (find lookalikes), threat intel (are they bad?), and attribution (who runs them?) are three different teams with three different tools. The handoff loses context every time.
What the graph does: chain them. Generate registered lookalikes with whisper.variants(), then pivot the hits through a resolution check for the hosting network and through explain() for a verdict — typosquat hunting, triage, and attribution in one flow.
// 1) Registered lookalikes of the brand
CALL whisper.variants("paypal.com")
YIELD variant, method, exists, confidenceLabel
WHERE exists
RETURN variant, method, confidenceLabel
LIMIT 15
Feed the hits into a resolution check to see which are live, where they point, and on whose network:
// 2) Triage the lookalikes: where they point, whose network
UNWIND ["paypall.com", "payppal.com"] AS d
MATCH (h:HOSTNAME {name: d})-[:RESOLVES_TO]->(ip:IPV4)
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
RETURN d AS lookalike, ip.name AS ip, ip.threatLevel AS threat, a.name AS asn
LIMIT 10
Tip:
exists: truefromwhisper.variants()means registered, not malicious — the second query is what turns a candidate into a lead. Both of these doubled-letter lookalikes land on one address on one network, which is the shape worth chasing: pull that IP's co-tenants (Recipe 3) to see the rest of the cluster, and runexplain()on it for the feeds behind its threat level. A lookalike whosethreatLevelcomes back empty simply isn't on a feed yet — judge it on where it points and what it shares with, not on the absent score. An agent does all the legs back-to-back; see Recipe 10.
Recipe 8 — Routing-layer cross-check: hijack signal + RPKI + owner
Why it's hard with flat tools: a MOAS alert ("this prefix is announced by two ASNs") is meaningless without knowing whether the competing origin is RPKI-authorized and who actually owns the address space. That's a BGP looking glass, an RPKI validator, and a WHOIS lookup.
What the graph does: the MOAS conflict, the ROA authorization, and the owner all hang off the same prefix. CONFLICTS_WITH names the competing origins (the announcer itself is excluded), moasIsLegitimate is the graph's read on whether the multi-origin state looks like normal multi-homing, and a ROA that reaches both the origin (ROA_AUTHORIZES_ORIGIN) and this exact block (ROA_AUTHORIZES_PREFIX) is the one that tells you RPKI actually backs this announcement. Multi-origin state is live routing data, so lead with the discovery form, which finds whatever is in conflict right now.
// Prefixes in conflict right now → conflicting origins + ROAs authorizing each for this block
MATCH (ap:ANNOUNCED_PREFIX)-[:CONFLICTS_WITH]->(origin:ASN)
WITH ap, origin LIMIT 15
OPTIONAL MATCH (p:PREFIX {name: ap.name})<-[:ROA_AUTHORIZES_PREFIX]-(roa:ROA)-[:ROA_AUTHORIZES_ORIGIN]->(origin)
RETURN ap.name AS prefix, ap.moasIsLegitimate AS looks_legitimate,
origin.name AS conflicting_origin, count(roa) AS authorizing_roas
LIMIT 15
Sample output (captured 2026-09-02):
[
{"prefix": "164.163.138.0/24", "looks_legitimate": false, "conflicting_origin": "AS1", "authorizing_roas": 0},
{"prefix": "191.241.191.0/24", "looks_legitimate": false, "conflicting_origin": "AS10", "authorizing_roas": 0},
{"prefix": "195.74.62.0/23", "looks_legitimate": false, "conflicting_origin": "AS10", "authorizing_roas": 0}
]
Empty result: CONFLICTS_WITH and the ROA_* edges are coverage-scoped routing layers. Zero rows from the discovery form means nothing is in conflict in the current table; zero rows from the anchored form (MATCH (ap:ANNOUNCED_PREFIX {name: "<prefix>"})-[:CONFLICTS_WITH]->(origin:ASN)) means that prefix is not currently in conflict, which is the answer you were hoping for. A count of zero in authorizing_roas means no ROA was issued for this exact block — RPKI can still cover the announcement through a shorter covering prefix, so read it as "nothing authorizes it here," not as proof of a hijack.
Tip: To check one prefix you care about, swap the first line for the anchored form above; reach the prefix from any IP you're watching via
(ip:IPV4 {name: "..."})-[:ANNOUNCED_BY]->(ap), whereisMoasis the quick yes/no. Walk both ROA legs, not just the origin one:ROA_AUTHORIZES_ORIGINon its own counts every ROA that names that AS anywhere in the address space, which for a large transit network runs to four figures and says nothing about the block in front of you. The RPKI side of a ROA lands on aPREFIXnode, not on theANNOUNCED_PREFIX, so join them by name. Any specific example prefix will eventually settle, which is why the discovery form is the one to build on and the anchored form is the one to schedule against your own space. For the full scored picture,CALL explain("AS<number>")rolls up an AS's threat posture.
Recipe 9 — Physical + logical footprint of a network
Why it's hard with flat tools: "where does Cloudflare's network physically sit, and where does it peer?" isn't in any DNS tool. The physical internet — facilities, IXPs, cables — is a separate, harder-to-source dataset entirely.
What the graph does: an ASN connects to the buildings it occupies (AS_PRESENT_AT) and the exchanges it joins (IX_MEMBER) in the same query surface as everything else. This is the attribution story's last mile.
// AS13335 (Cloudflare): facilities + internet exchanges
MATCH (a:ASN {name: "AS13335"})
OPTIONAL MATCH (a)-[:AS_PRESENT_AT]->(f:FACILITY)
WITH a, collect(DISTINCT f.name)[..10] AS facilities
OPTIONAL MATCH (a)-[:IX_MEMBER]->(ix:INTERNET_EXCHANGE)
RETURN a.name AS asn, facilities,
collect(DISTINCT ix.name)[..10] AS internet_exchanges
LIMIT 1
Tip: Both legs are high-fan-out for a large transit network, so each gets its own bounded
collect. Anchor an ASN with theAS-prefixedname(AS13335), never a bare number, and avoidCONTAINSonASN.name— it falls back to a scan. For peering adjacency useBGP_NEIGHBOR(ASN → ASN); it also works inside a variable-length pattern, and on an undirected peering walk addWHERE n <> a, because a mesh routinely returns to the origin AS.
Recipe 10 — Run it autonomously over MCP
Why it's hard with flat tools: an AI agent doing this investigation with flat APIs burns its whole context window on glue — paginating, reformatting, reconciling — instead of reasoning. And every infrastructure claim it makes is a guess from stale training data.
What the graph does: WhisperGraph is MCP-native. Point any MCP client (Claude, ChatGPT, Cursor) at https://mcp.whisper.security and the agent gets seven read-only tools: query for raw Cypher (every pattern on this page, procedures included), explain_indicator for one-call verdicts with coverage, identify for who runs a host, explain_schema for schema introspection, read_docs for these docs, and list_workflows / run_workflow to discover and execute the guided investigations from the workflow gallery by slug. A multi-hop investigation collapses into one tool call, and every claim cites the exact Cypher that ran.
The same composite report from Recipe 6, as a REST call an agent (or your SOAR) makes directly:
curl -s -A "whisper-client/1.0" \
https://graph.whisper.security/api/query \
-H "Content-Type: application/json" \
-H "X-API-Key: $WHISPER_API_KEY" \
-d '{"query":"MATCH (h:HOSTNAME {name:\"paypal.com\"})-[:RESOLVES_TO]->(ip:IPV4)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME) RETURN ip.name, n.name, ip.isThreat, ip.threatScore LIMIT 5"}'
A typical agent loop runs the patterns in sequence on one indicator: identify the host class → explain the verdict with its sources → pivot blast radius for siblings → pull history for the registrar or origin change → and write the citation from the edges it walked. An agent that generates its own Cypher should be handed the Graph Schema: a model trained on another vendor's schema will confidently invent labels this graph has never had (Domain, Certificate, TECHNIQUE), and those now error rather than returning empty. See the AI & Agents section and MCP setup to connect a client.
From a submarine cable to the clicks it carries
WhisperGraph joins the physical internet to the logical one, so you can trace a subsea cable to the data centers it lands near, the networks present there, the prefixes they route, and ultimately the addresses served — the full physical-to-DNS chain that no DNS-only tool can express. Run it a step at a time, then chain the whole path in one statement.
1 · Where the cable lands, and the facilities nearby:
2 · The networks present at one of those facilities:
MATCH (f:FACILITY {name: "Teraco CT1 Cape Town, South Africa"})<-[:AS_PRESENT_AT]-(a:ASN)
RETURN a.name AS network
LIMIT 15
3 · The prefixes a network routes (each resolves on to IPs and the hostnames they serve):
MATCH (a:ASN {name: "AS37662"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
RETURN p.name AS prefix
LIMIT 15
Or collapse the chain into a single traversal:
MATCH (cab:SUBMARINE_CABLE {name: "2Africa"})-[:CABLE_LANDS_AT]->(:CABLE_LANDING)
-[:LANDING_NEAR]->(f:FACILITY)<-[:AS_PRESENT_AT]-(a:ASN)
RETURN f.name AS facility, collect(DISTINCT a.name)[0..10] AS networks
LIMIT 10
That is the cable-to-clicks path: physical infrastructure on one end, the networks and routes that turn it into reachable services on the other — one graph, one query language.
Working in batches
Most real work arrives as a list: a watchlist of domains, a page of firewall IPs, a client's whole brand portfolio. Sending fifty single-indicator queries is the slowest way to answer that, and the round trip you save is the smaller half. What matters is what comes back: not fifty flat lookups you then stitch together, but one answer per indicator that already carries the join — the verdict and the operator and the routing posture and the country, resolved against each other before it left the server. Start here before you write a loop.
How do I get a verdict with coverage for a whole watchlist?
Your pipeline pulled a handful of indicators off an alert. You want a verdict for each, plus enough context to know whether the verdict means anything, without firing one request per indicator. coverage is the column that decides whether you may close on clean.
// One row per indicator, with coverage so you can tell no-data from clean
CALL whisper.assess(["1.1.1.1", "185.220.101.1", "google.com", "8.8.8.8", "45.148.10.35"])
YIELD host, label, band, coverage
RETURN host, label, band, coverage
LIMIT 10
Returns: host, label, band, coverage
Sample output (captured 2026-09-02):
[
{"host": "1.1.1.1", "label": "benign-allowlisted", "band": "INFO", "coverage": "known-clean"},
{"host": "185.220.101.1", "label": "ambiguous", "band": "LOW", "coverage": "ambiguous"},
{"host": "google.com", "label": "benign-allowlisted", "band": "NONE", "coverage": "known-clean"}
]
Costs: one procedure call, no traversal, mixed IPs and hostnames in one list; the exact columns are host, label, band, coverage, evidence, signals and a YIELD naming anything else is rejected rather than ignored.
Tip: known-clean — coverage: known-clean. In coverage, no malicious evidence. means the graph has positive evidence this indicator is benign; ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. means the evidence points both ways; a no-data answer means nobody has said anything about it either way — which is not the same as safe. Read
coveragebefore you act onband. A single host string works too when the list is one long.
From here, → How do I get owner, network, country and band for a mixed list?
How do I get owner, network, country and band for a mixed list?
Your pipeline needs more than a verdict per indicator — it needs the joined answer: who operates the address, which network announces it, where that network is registered, and how bad it looks. That is the row a SOAR enriches an alert with, for IPs and hostnames alike, in one call.
// One call: owner, country, ASN and threat band for a mixed list of indicators
CALL whisper.enrich(["1.1.1.1", "8.8.8.8", "github.com"])
YIELD name, owner, country, asn, band, prevalence, coverage
RETURN name, owner, country, asn, band, prevalence, coverage
LIMIT 10
Returns: name, owner, country, asn, band, prevalence, coverage
Sample output (captured 2026-09-02):
[
{"name": "1.1.1.1", "owner": "Cloudflare, Inc.", "country": "US", "asn": "AS13335", "band": "INFO", "prevalence": null, "coverage": "full"},
{"name": "8.8.8.8", "owner": "Google LLC", "country": "US", "asn": "AS15169", "band": "INFO", "prevalence": null, "coverage": "full"},
{"name": "github.com", "owner": "GitHub, Inc.", "country": "US", "asn": "AS36459", "band": "NONE", "prevalence": 10, "coverage": "full"}
]
Costs: one procedure call that does the host → IP → prefix → ASN join server-side, so it costs you no traversal at all; rows are de-duplicated by canonical name, so the output is not positionally aligned to your input array — join back by name, never by index.
Tip: three things to hold onto.
owneris a network attribution, not a threat attribution — it names the operator of the origin AS behind one representative resolved IP, which is why a malicious host on a reputable cloud shows a reputable owner.prevalenceis a popularity rank where lower is more prevalent,nullmeans unranked, and it is only ever populated for hostnames. And the same semantics arrive on the response envelope as anenrich-semanticsadvisory, so a client can read them rather than remember them (What is the response telling me outside the rows?).
From here, → How do I score a list of indicators in one round trip? when you need the numeric score behind the band.
How do I score a list of indicators in one round trip?
You want the numeric score and level for every indicator in a batch so you can sort and threshold downstream. UNWIND turns the list into rows and each row gets its own explain().
// Verdict for a whole watchlist in one round trip
UNWIND ["1.1.1.1", "45.148.10.35", "8.8.8.8"] AS ind
CALL explain(ind) YIELD indicator, score, level
RETURN indicator, score, level
LIMIT 10
Returns: indicator, score, level
Sample output (captured 2026-09-02):
[
{"indicator": "1.1.1.1", "score": 0.85, "level": "INFO"},
{"indicator": "45.148.10.35", "score": 16.33, "level": "LOW"},
{"indicator": "8.8.8.8", "score": 0.85, "level": "INFO"}
]
Costs: one round trip but one explain() per row on the server, so keep the list to what you will actually act on; for the cheap batch path read verdictLevel and verdictBlocking straight off the nodes instead.
Tip:
explain()changes its column set depending on what you hand it — a domain, an IP, an ASN and a CIDR do not all return the same fields. Name the columns you want inYIELDand keep them from a single shape.indicator,scoreandlevelare safe together for every indicator type; mixing in a column that only exists for one shape is rejected up front rather than returning a half-empty row.
From here, → Recipe 2 — The full verdict with its evidence chain for the sources behind any one score.
How do I pull registration history for a list of domains?
You are profiling a portfolio of lookalike domains and want each one's registration timeline — created, updated, expiry, registrar, registrant — to spot the ones registered in a burst or quietly transferred. One call, one row per historical snapshot.
// One call, a whole watchlist of registration histories
UNWIND ["booking.com", "airbnb.com", "expedia.com"] AS d
CALL whisper.history.whois(d)
YIELD indicator, createDate, updateDate, expiryDate, registrar, registrant, country
RETURN indicator, createDate, updateDate, expiryDate, registrar, registrant, country
LIMIT 10
Returns: indicator, createDate, updateDate, expiryDate, registrar, registrant, country
Sample output (captured 2026-09-02):
[
{"indicator": "booking.com", "createDate": "1998-04-17", "updateDate": "2021-02-26", "expiryDate": "2021-04-15", "registrar": "MarkMonitor, Inc.", "registrant": "Booking.com B.V.", "country": "NL"},
{"indicator": "booking.com", "createDate": "1998-04-17", "updateDate": "2024-08-02", "expiryDate": "2025-04-16", "registrar": "MarkMonitor, Inc.", "registrant": "Booking.com B.V.", "country": "NL"}
]
Costs: one history call per row, so the LIMIT caps snapshots, not domains — raise it or page when the list is long; needs an API key.
Tip: you get one row per historical snapshot, not one per domain — that is the point. Sort by
updateDateto see the ownership timeline and watch for aregistrantthat changes; older snapshots also frequently carry a real registrant where the current record says "REDACTED FOR PRIVACY". A subdomain in the list folds up to its registrable parent, andregistrableDomainnames which.
From here, → How do I query history from a scheduled job without the columns shifting?
How do I list subdomains for several apexes without one starving the rest?
You have a handful of apex domains and want their known subdomains without one query per domain — and without a fan-out that stalls on the big one. The bound goes before the aggregation, and a shown column tells you when you hit it.
// Bounded subdomain pull for a list of apexes
UNWIND ["stripe.com", "github.com"] AS d
MATCH (h:HOSTNAME {name: d})<-[:CHILD_OF]-(sub:HOSTNAME)
WITH d, sub LIMIT 200
RETURN d AS domain, collect(sub.name)[0..10] AS subdomains, count(sub) AS shown
LIMIT 10
Returns: domain, subdomains, shown
Sample output (captured 2026-09-02):
[
{"domain": "stripe.com", "subdomains": ["_custom-email-domain.stripe.com", "_spf.stripe.com", "answers.stripe.com", "api.stripe.com"], "shown": 87},
{"domain": "github.com", "subdomains": ["0.github.com", "000.github.com", "00010011.github.com", "001.github.com"], "shown": 113}
]
Costs: one indexed anchor per apex and one reverse CHILD_OF hop, capped in total before the collect; CHILD_OF points upward (sub → apex), so the subdomains sit on the reverse arrow.
Tip: the
WITH d, sub LIMIT 200is doing the real work — it caps the total rows before the collect, so one large domain in the list cannot take everything.showntells you when you hit the cap: in the sample the two values sum to the bound, so the second apex received only what the first left over. Raise the bound, or page the big domain separately with a stableORDER BY sub.name SKIP … LIMIT …. Acollect(...)[0..N]slice on its own does not bound anything — it is applied after the collect has materialised every row.
From here, → Recipe 3 — Blast radius from one indicator to the campaign once you have the names.
Which SaaS vendor owns each of these egress addresses?
Your firewall log is full of outbound destinations and you want to know which are just SaaS platforms your own company uses before anyone gets paged. The vendor is the attribution when there is one; the ASN is the fallback when there is not.
// Which SaaS vendor owns each of these egress addresses?
UNWIND ["13.107.42.14", "1.1.1.1"] AS x
MATCH (ip:IPV4 {name: x})
OPTIONAL MATCH (ip)-[:DELEGATED_TO]->(v:VENDOR)
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
RETURN x AS ip, collect(DISTINCT v.name)[0..3] AS vendors, a.name AS asn
LIMIT 10
Returns: ip, vendors, asn
Sample output (captured 2026-09-02):
[
{"ip": "13.107.42.14", "vendors": ["azure"], "asn": "AS8068"},
{"ip": "1.1.1.1", "vendors": [], "asn": "AS13335"}
]
Costs: one indexed anchor per address, one optional vendor hop and one optional two-hop routing arm; both arms must stay OPTIONAL, because a plain MATCH on the vendor step would silently drop every IP that has no vendor, which is most of them.
Tip: an empty
vendorslist is a real answer — it means the address is not in a published SaaS egress range, so the ASN is your best attribution.VENDORnames are lowercase slugs (azure,aws,okta). When the delegation sits on the prefix rather than the address, walk(ip)-[:BELONGS_TO]->(:PREFIX)-[:DELEGATED_TO]->(v)instead.
From here, → Which of these source IPs are Tor, VPN or proxy infrastructure?
Which of these source IPs are Tor, VPN or proxy infrastructure?
Before you treat a set of source IPs as attributable, you want to know which of them are Tor exits, VPN endpoints, open proxies, or bulk-storage and paste destinations. Those flags live on the node, so the batch is one indexed read per address.
// Flag anonymising and exfiltration infrastructure across a batch of indicators
UNWIND ["185.220.101.1", "1.1.1.1", "8.8.8.8"] AS n
MATCH (x:IPV4 {name: n})
RETURN x.name AS indicator,
coalesce(x.isTor, false) AS tor,
coalesce(x.isVpn, false) AS vpn,
coalesce(x.isProxy, false) AS proxy,
coalesce(x.isAnonymizer, false) AS anonymizer,
coalesce(x.isExfilDestination, false) AS exfil_destination,
x.threatLevel AS threat_level
LIMIT 10
Returns: indicator, tor, vpn, proxy, anonymizer, exfil_destination, threat_level
Sample output (captured 2026-09-02):
[
{"indicator": "185.220.101.1", "tor": true, "vpn": false, "proxy": false, "anonymizer": true, "exfil_destination": false, "threat_level": "LOW"},
{"indicator": "1.1.1.1", "tor": false, "vpn": false, "proxy": false, "anonymizer": true, "exfil_destination": false, "threat_level": "INFO"}
]
Costs: indexed reads only, no traversal; wrap each flag in coalesce(..., false) so an address the graph has never seen returns false rather than null, or your downstream code has to handle three states instead of two.
Tip:
anonymizeris broader thantor— it also covers public resolvers and privacy relays, which is why1.1.1.1trips it. Being an anonymiser is a routing fact, not a verdict.isExfilDestinationis the one to alert on: it marks bulk-storage, paste and anonymous-upload destinations, which is a very different finding from an inbound scanner. The same node also carriesisC2,isMalware,isPhishingandisThreatfor the full boolean sweep in one row.
From here, → How do I get a verdict with coverage for a whole watchlist? for the reconciled verdict behind the flags.
How many ROAs authorize each network in a list?
You are scoring routing hygiene across a list of autonomous systems and want the simplest signal first: how many RPKI ROAs authorize each one to originate prefixes. A network with none is unsigned; a network with thousands has done the work.
// ROA count per ASN, across a batch
UNWIND ["AS13335", "AS15169", "AS36459"] AS an
MATCH (asn:ASN {name: an})
CALL { WITH asn MATCH (asn)<-[:ROA_AUTHORIZES_ORIGIN]-(r:ROA) RETURN count(r) AS roas }
RETURN an AS asn, roas
LIMIT 10
Returns: asn, roas
Sample output (captured 2026-09-02):
[
{"asn": "AS13335", "roas": 55893},
{"asn": "AS15169", "roas": 1954},
{"asn": "AS36459", "roas": 10}
]
Costs: one indexed anchor per network and a single-hop count inside a CALL { } subquery, which keeps the count scoped per input row — an aggregate in the outer query would collapse the batch into one number — and keeps each arm shallow.
Empty result: ROA_AUTHORIZES_ORIGIN is a coverage-scoped routing layer. A count of zero means no ROA in the current table names that AS as an origin — its announcements are unverifiable, which is a finding — never that the network does not exist. An AS missing from the output altogether was not matched by name; anchor with the AS-prefixed form (AS13335).
Tip: read the count as a floor on intent, not a score: a large count means the operator signs its space, zero means nothing is signed. Pair it with the per-prefix posture —
rpkiStatus,rpkiInvalidReason,isMoas,roaAsnon anANNOUNCED_PREFIXreached from any host's IP throughANNOUNCED_BY— to see whether the signing actually matches what is announced.
From here, → Recipe 8 — Routing-layer cross-check: hijack signal + RPKI + owner
What kind of thing is each token in an unlabelled list?
A list arrives from a report, a log or an agent, and nothing in it is labelled. Some entries are IPs, some hostnames, some ASNs, and some are not in the graph at all. You want one typed row per input, misses included — which is also the batch existence check.
// One typed row per token, misses included
UNWIND ["1.1.1.1", "github.com", "AS13335", "definitely-not-a-real-domain-xyzzy.com"] AS tok
OPTIONAL MATCH (seed {name: tok})
RETURN tok AS token,
CASE WHEN seed IS NULL THEN "unknown" ELSE labels(seed)[0] END AS kind,
coalesce(seed.threatLevel, "n/a") AS threat_level
LIMIT 10
Returns: token, kind, threat_level
Sample output (captured 2026-09-02):
[
{"token": "1.1.1.1", "kind": "IPV4", "threat_level": "INFO"},
{"token": "github.com", "kind": "HOSTNAME", "threat_level": "NONE"},
{"token": "AS13335", "kind": "ASN", "threat_level": "NONE"},
{"token": "definitely-not-a-real-domain-xyzzy.com", "kind": "unknown", "threat_level": "n/a"}
]
Costs: one indexed name lookup per token, no traversal; the anchor is unlabelled on purpose so one lookup serves every type, and labels(seed)[0] reports what it landed on.
Tip:
OPTIONAL MATCHis what makes this safe — a plainMATCHsilently drops the tokens that do not exist, so every row comes back resolved and you cannot tell which inputs were misses. Names are stored lowercase, so fold the case in your own code first. For a single token you cannot classify at all, or one that needs prefix or suffix matching,CALL whisper.search("<token>")routes by detected type to a bounded lookup and returns an explicitwarningrow instead of scanning; this shape is for lists where an exact-name lookup will do.
From here, → How do I get a verdict with coverage for a whole watchlist? with the tokens that resolved.
Built for automation
A query you run once by hand can be sloppy. A query a pipeline runs every hour cannot: its columns have to be the same on every call, it has to carry its own notices rather than hiding them in rows, and it should accept the input the upstream system actually produces. These three shapes are what make the patterns above safe to schedule.
How do I query history from a scheduled job without the columns shifting?
You are wiring history lookups into a pipeline that runs the same query on a schedule, and you need a column set that will not shift under a fixed YIELD between one indicator and the next. Call the single-shape variant for the indicator type and YIELD from that one shape.
// Domains → the fixed WHOIS shape
CALL whisper.history.whois("cloudflare.com")
YIELD indicator, registrableDomain, registrar, registrant, country,
createDate, updateDate, expiryDate, nameServers
RETURN registrar, registrant, country, createDate, updateDate, nameServers
LIMIT 3
Returns: registrar, registrant, country, createDate, updateDate, nameServers
Sample output (captured 2026-09-02):
[
{"registrar": "CloudFlare, Inc.", "registrant": "CloudFlare, Inc.", "country": "US", "createDate": "2009-02-17", "updateDate": "2017-06-07", "nameServers": "ns3.cloudflare.com|ns4.cloudflare.com|ns5.cloudflare.com|ns6.cloudflare.com|ns7.cloudflare.com"},
{"registrar": "Cloudflare, Inc.", "registrant": "DATA REDACTED", "country": "US", "createDate": "2009-02-17", "updateDate": "2020-04-17", "nameServers": "ns3.cloudflare.com|ns4.cloudflare.com|ns5.cloudflare.com|ns6.cloudflare.com|ns7.cloudflare.com"}
]
Costs: one procedure call, one row per historical snapshot; the WHOIS shape is a fixed contract, so a scheduled YIELD stays valid across releases; needs an API key.
Tip: the general
whisper.history(indicator)is multi-shape — it emits WHOIS columns for a domain and routing columns for an IP, ASN or prefix — so aYIELDthat names columns from both shapes (createDateandprefix, say) can never be satisfied by one row and is rejected up front, with amulti_shape_yieldentry insuggestions[]your client can branch on. From automation, always call the single-shape variant.nameServersis a|-joined string, not a list, so split it client-side.
From here, → What routing history do I get for an IP, with stable columns?
What routing history do I get for an IP, with stable columns?
The same rule for the routing side: an IP, ASN or prefix goes to whisper.history.bgp, which always emits the same routing columns — which network originated which prefix, over which window, seen by what share of vantage points.
// IPs / ASNs / prefixes → the fixed routing shape
CALL whisper.history.bgp("8.8.8.8")
YIELD indicator, type, origin, prefix, startTime, endTime, visibility, peersSeing, cached
RETURN origin, prefix, startTime, endTime, visibility, cached
LIMIT 5
Returns: origin, prefix, startTime, endTime, visibility, cached
Sample output (captured 2026-09-02):
[
{"origin": "AS701", "prefix": "8.0.0.0/6", "startTime": "2013-12-05T00:00:00", "endTime": "2013-12-16T23:59:59", "visibility": 0.0328, "cached": false},
{"origin": "AS3352", "prefix": "8.0.0.0/7", "startTime": "2007-08-15T00:00:00", "endTime": "2007-08-26T23:59:59", "visibility": 0.1761, "cached": false}
]
Costs: one call against a routing-history backend, so expect a longer round trip than an anchored read and a longer one still for an ASN-wide question; keep the LIMIT; needs an API key.
Tip: note the routing column is spelled
peersSeing— it is the contract, so spell it that way.visibilityis the share of vantage points that saw the announcement, so a low-visibility row is a partial or leaked route rather than the network's steady state.cachedtells you whether the row came from a warm read; if a cold read comes back empty, retry once before you conclude there is no history.
From here, → Recipe 8 — Routing-layer cross-check: hijack signal + RPKI + owner to check the current table against the history.
What is the response telling me outside the rows?
A successful response can carry non-fatal notices — the API's way of telling you it quietly did something on your behalf, such as folding a subdomain up to its registrable parent. They arrive in a top-level advisories[] array beside columns, rows and statistics, so a client reads them without scraping row data.
// The fold emits a whois-parent-fold advisory at the top level of the response
CALL whisper.history.whois("www.cloudflare.com")
YIELD indicator, registrableDomain, registrar
RETURN indicator, registrableDomain, registrar
LIMIT 1
Returns: indicator, registrableDomain, registrar — plus, on the envelope, advisories[]
Response (abridged, captured 2026-09-02):
{
"columns": ["indicator", "registrableDomain", "registrar"],
"rows": [
{"indicator": "www.cloudflare.com", "registrableDomain": "cloudflare.com", "registrar": "CloudFlare, Inc."}
],
"advisories": [
{
"kind": "whois-parent-fold",
"message": "WHOIS shown for registrable parent cloudflare.com (queried www.cloudflare.com)",
"queried": "www.cloudflare.com",
"resolved": "cloudflare.com"
}
]
}
Costs: nothing beyond the query itself; the advisory lives on the response envelope, not in a row, so it survives any YIELD / RETURN projection — you get it even when your RETURN names none of the folded columns.
Tip: when there is nothing to report the channel is empty and the
advisorieskey is omitted entirely, so test for its presence rather than expecting an empty array.queriedandresolvedare omitted when they do not apply to an advisory kind, so read them defensively.whisper.enrichuses the same channel for itsenrich-semanticsnotice.
From here, → Can I pass a URL instead of a host? for the fold that produces this advisory.
Can I pass a URL instead of a host?
Your input arrives as a full URL — a link pulled from an email, a log line, a crawler — and you do not want to strip it to a host before every call. The agent-facing procedures fold a URL anchor to its host for you: https://host/path?q=1 is read as host, scheme, path and query dropped.
// A full URL is folded to its host before identification
CALL whisper.identify("https://github.com/torvalds/linux")
YIELD host, vendor_id, canonical_name, host_class
RETURN host, vendor_id, canonical_name, host_class
LIMIT 5
Returns: host, vendor_id, canonical_name, host_class
Sample output (captured 2026-09-02):
[{"host": "github.com", "vendor_id": "github", "canonical_name": "Github", "host_class": "multi_tenant_user_content"}]
Costs: identical to the host form; the fold happens before the lookup and costs nothing. whisper.identify, whisper.assess, whisper.walk and the history procedures all fold URLs, and whisper.assess / whisper.assessUrl take either a single string or a list, so a one-off call needs no list wrapper.
Tip: the two folds compose.
whisper.history.whois("https://www.cloudflare.com/pricing")first folds the URL to the hostwww.cloudflare.com, then folds that subdomain up to its registrable parentcloudflare.com, and thewhois-parent-foldadvisory reports the host it started from. When the path itself is the question — a kit path, a download URL on a multi-tenant host — usewhisper.assessUrl, which scores the path separately from the apex.
From here, → Recipe 5 — Identity is not a verdict (gate on coverage)
Where to go next
- Use Cases — guided workflows you can run in the browser, organized by domain.
- Graph Schema — every label, edge, and property, with the direction traps spelled out.
- Procedures — full
CALLsignatures forexplain(),whisper.variants(),whisper.history.whois()/whisper.history.bgp(),whisper.enrich(), andwhisper.origins(). - Threat Feeds & Categories — the 134 feeds and 32 categories behind every verdict.
- AI & Agents — connect an MCP client so an agent runs all of this itself.