Cross-Layer Patterns
Ten patterns that compose attribution, verdicts, blast radius, history, and identity into one sourced investigation.
On this page (12)
- 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
- 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. 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. 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.
// 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.
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.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, so sign in to run it — there is no card to enter. 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
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.
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), 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.
// MOAS prefix → conflicting origins + ROAs authorizing each for this block
MATCH (ap:ANNOUNCED_PREFIX {name: "141.101.225.0/24"})-[:CONFLICTS_WITH]->(origin:ASN)
OPTIONAL MATCH (p:PREFIX {name: ap.name})<-[:ROA_AUTHORIZES_PREFIX]-(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). 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. A count of zero 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. 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.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. 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.
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 76 feeds and 31 categories behind every verdict.
- AI & Agents — connect an MCP client so an agent runs all of this itself.