Cross-Layer Patterns
Ten patterns that compose attribution, verdicts, blast radius, history, and identity into one sourced investigation.
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. The full-context play is to compose 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) into one sourced report. 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. Anonymous access gets you 2 hops; a free key raises the depth cap to 3 and unlocks the full procedure set; paid plans go deeper still. Most cross-layer patterns need three or more hops, so you will want a key — noted where it matters. See Getting Started for the key, the Graph Schema for the full model, and Procedures for the CALL signatures.
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. (Crosses more than two hops, so run it with a key.)
// 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.threatLevel AS threat, ip.isThreat AS isThreat
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; anulllevel is the answer "not on any feed," not an error.
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")
[{
"indicator": "185.220.101.1",
"type": "ip",
"found": true,
"score": 5.57,
"level": "MEDIUM",
"explanation": "185.220.101.1 is listed in 3 threat feed(s). Score 5.6 (Informational - minimal risk).",
"factors": [
"Listed in 3 source(s) with combined weight 2.20",
"Recency boost: ×1.2 (last seen 8 hours ago)",
"Final score: 4.40 × 1.2 × 1.05 = 5.57"
],
"sources": [
{"feedId": "dan-tor-exit", "weight": 0.5, "firstSeen": "2026-06-23T01:26:36Z", "lastSeen": "2026-06-26T13:56:27Z"},
{"feedId": "stamparm-ipsum", "weight": 1.2, "firstSeen": "2026-06-22T19:25:54Z", "lastSeen": "2026-06-26T19:30:43Z"}
]
}]
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.CALL explain("AS13335")andCALL explain("185.220.101.0/24")work the same way for an ASN or a CIDR range.
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.
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: whisper.history() returns timestamped snapshots — WHOIS history for a domain (create/update dates, registrar, registrant, nameservers), BGP 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("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:
whisper.history()is the one composite-report ingredient that needs a key — it is not available on the anonymous tier. BGP history over a large network can take many seconds: keep aLIMITon 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
Tip: Always read
coveragealongside the label.known-cleanmeans the graph has real signal and considers the host clean;structural-onlymeans the answer rests on graph structure rather than a confirmed match; a host the graph has no data on comes back with a coverage value that says exactly that — don't downgrade an alert on the strength of it.host_classmatters too: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.
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. (The deepest leg crosses more than two hops — run it with a key.)
// 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.threatLevel AS threat, ip.isThreat AS isThreat
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 ["aypal.com", "papal.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. Anullthreat level means it isn't on a feed yet: pivot the IP throughexplain()and pull its co-tenants (Recipe 3) to expand the cluster. 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), and ROA_AUTHORIZES_ORIGIN tells you which of them RPKI actually backs.
// MOAS prefix → conflicting origins + how many ROAs authorize each
MATCH (ap:ANNOUNCED_PREFIX {name: "216.168.228.0/24"})-[:CONFLICTS_WITH]->(origin:ASN)
OPTIONAL MATCH (roa:ROA)-[:ROA_AUTHORIZES_ORIGIN]->(origin)
RETURN ap.name AS prefix, origin.name AS conflicting_origin,
count(roa) AS authorizing_roas
LIMIT 15
Tip: The
isMoasflag on an announced prefix is the quick yes/no for multi-origin status — reach the prefix from any IP you're watching via(ip:IPV4 {name: "..."})-[:ANNOUNCED_BY]->(ap). A conflicting origin with zero authorizing ROAs is the high-signal case. Multi-origin state shifts as routes change, so any specific example prefix may settle; the query shape is what stays useful. 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.
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 six read-only tools: query for raw Cypher (every pattern on this page, procedures included), explain_indicator for one-call verdicts with coverage, 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.threatLevel 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. 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. Anonymous traversals reach two hops; run each step below, or chain the whole path with a free key.
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
With a key you can 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.
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(), andwhisper.origins(). - Threat Feeds & Categories — the 43 feeds and 25 categories behind every verdict.
- AI & Agents — connect an MCP client so an agent runs all of this itself.