Skip to contentSkip navigation

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)

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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. Return isThreat and threatScore for the posture: both are always set — false and 0.0 on an address no feed has listed. threatLevel is 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.enrich does 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

json
[{
  "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 factors and sources straight 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 in YIELD: a bare CALL explain(...) also hands back an advisory column that only some indicators carry (explain("1.1.1.1") returns allowlist-vouched; most indicators return nothing there), and an empty column in the middle of a report invites the wrong conclusion. YIELD * is rejected on explain, because the emitted columns depend on the indicator type. CALL explain("AS13335") and CALL 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 s and filter WHERE 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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 on HAS_REGISTRAR and NAMESERVER_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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

cypher · runnablegraph.whisper.securitySign in to run
// 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 LIMIT on 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."

cypher · runnablegraph.whisper.securitySign in to run
// 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

cypher · runnablegraph.whisper.securitySign in to run
// 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 coverage before band. Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean"; no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. means unknown, which is a different thing again; malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. and ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. mean there is evidence, whatever the band says. whisper.explain does not return coverage at all. Full contract: Coverage — what we looked at.

Tip: Read host_class too: multi_tenant_user_content means anyone can publish there, so one bad URL doesn't condemn the domain. When identify finds 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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_FOR points server → domain, so a domain's mail servers are reached backwards: (domain)<-[:MAIL_FOR]-(mx). The same direction trap applies to NAMESERVER_FOR. Wrap the mail leg in its own collect(...)[..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.

cypher · runnablegraph.whisper.securitySign in to run
// 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:

cypher · runnablegraph.whisper.securitySign in to run
// 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: true from whisper.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 run explain() on it for the feeds behind its threat level. A lookalike whose threatLevel comes 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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), where isMoas is the quick yes/no. Walk both ROA legs, not just the origin one: ROA_AUTHORIZES_ORIGIN on 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 a PREFIX node, not on the ANNOUNCED_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.

cypher · runnablegraph.whisper.securitySign in to run
// 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 the AS-prefixed name (AS13335), never a bare number, and avoid CONTAINS on ASN.name — it falls back to a scan. For peering adjacency use BGP_NEIGHBOR (ASN → ASN); it also works inside a variable-length pattern, and on an undirected peering walk add WHERE 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:

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

Live · graph.whisper.security
read-only Cypher

Copy as
Open it in the Console

2 · The networks present at one of those facilities:

cypher · runnablegraph.whisper.securitySign in to run
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):

cypher · runnablegraph.whisper.securitySign in to run
MATCH (a:ASN {name: "AS37662"})-[:ROUTES]->(p:ANNOUNCED_PREFIX)
RETURN p.name AS prefix
LIMIT 15

Or collapse the chain into a single traversal:

cypher · runnablegraph.whisper.securitySign in to run
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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 coverage before you act on band. 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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. owner is 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. prevalence is a popularity rank where lower is more prevalent, null means unranked, and it is only ever populated for hostnames. And the same semantics arrive on the response envelope as an enrich-semantics advisory, 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().

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 in YIELD and keep them from a single shape. indicator, score and level are 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 updateDate to see the ownership timeline and watch for a registrant that 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, and registrableDomain names 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 200 is doing the real work — it caps the total rows before the collect, so one large domain in the list cannot take everything. shown tells 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 stable ORDER BY sub.name SKIP … LIMIT …. A collect(...)[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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 vendors list is a real answer — it means the address is not in a published SaaS egress range, so the ASN is your best attribution. VENDOR names 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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: anonymizer is broader than tor — it also covers public resolvers and privacy relays, which is why 1.1.1.1 trips it. Being an anonymiser is a routing fact, not a verdict. isExfilDestination is 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 carries isC2, isMalware, isPhishing and isThreat for 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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, roaAsn on an ANNOUNCED_PREFIX reached from any host's IP through ANNOUNCED_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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 MATCH is what makes this safe — a plain MATCH silently 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 explicit warning row 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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 a YIELD that names columns from both shapes (createDate and prefix, say) can never be satisfied by one row and is rejected up front, with a multi_shape_yield entry in suggestions[] your client can branch on. From automation, always call the single-shape variant. nameServers is 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[
  {"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. visibility is 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. cached tells 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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
{
  "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 advisories key is omitted entirely, so test for its presence rather than expecting an empty array. queried and resolved are omitted when they do not apply to an advisory kind, so read them defensively. whisper.enrich uses the same channel for its enrich-semantics notice.

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.

cypher · runnablegraph.whisper.securitySign in to run
// 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):

json
[{"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 host www.cloudflare.com, then folds that subdomain up to its registrable parent cloudflare.com, and the whois-parent-fold advisory reports the host it started from. When the path itself is the question — a kit path, a download URL on a multi-tenant host — use whisper.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 CALL signatures for explain(), whisper.variants(), whisper.history.whois() / whisper.history.bgp(), whisper.enrich(), and whisper.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.