Attack Paths

What an external attack path is on this graph, which edges compose one, and what a traversal can and cannot establish.

Attack Paths Documentation

An external attack path is the chain of internet infrastructure that connects an attacker to a target: the hyperlink, the lookalike domain it points to, the IP that domain resolves to, the prefix announcing that IP, the ASN that routes it, the data center it sits in, the cable underneath. Each link is an edge here, so the whole route is one traversal instead of a dozen lookups stitched by hand.

The graph shows you the chain. It does not tell you who walked it — every edge below is an observation about infrastructure, and none of them is an attribution.

Key concepts: Attack path analysis · Choke point analysis · Infrastructure pivoting.

The edges an external path is made of

Seven layers are pre-joined into one graph, so a path crosses them without a join you write yourself. One row per layer, and the edge names are what you traverse.

LayerEdgesWhat that link answers
WebLINKS_TOWhich pages point at the target, and which point at the lure
DNSRESOLVES_TO · CHILD_OF · NAMESERVER_FOR · MAIL_FORWhere a name goes, and who answers for it
OwnershipREGISTERED_BY · HAS_EMAILWhich registration estate a name belongs to
RoutingANNOUNCED_BY · ROUTES · ROA_AUTHORIZES_ORIGINWhich ASN carries the IP, and whether that origin is authorised to announce it
GeographyLOCATED_INWhich city and country the IP sits in
ThreatLISTED_IN · explain()Which of 76 feeds have seen it, with weights and first/last-seen timestamps
PhysicalAS_PRESENT_AT · CABLE_LANDS_AT · LANDING_NEARWhich buildings a network occupies, and which cable lands beside them

Tracing one path, end to end

Start where the alert starts — a name — and follow resolution into routing in one statement.

MATCH (h:HOSTNAME {name: "github.com"})-[:RESOLVES_TO]->(ip:IPV4)
OPTIONAL MATCH (ip)-[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
RETURN ip.name AS ip, a.name AS asn
LIMIT 5

Then keep going where other tooling stops: from a service to the named buildings its network occupies. Narrow to a few ASNs with WITH DISTINCT a LIMIT 3 before fanning out, because a large network can be present in hundreds of facilities.

MATCH (h:HOSTNAME {name: "cloudflare.com"})-[:RESOLVES_TO]->(ip:IPV4)
      -[:ANNOUNCED_BY]->(:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)
WITH DISTINCT a LIMIT 3
MATCH (a)-[:AS_PRESENT_AT]->(f:FACILITY)
RETURN a.name AS asn, collect(DISTINCT f.name)[0..8] AS facilities
LIMIT 5

The same layer works from the other end. Anchor on a submarine cable and walk up to the facilities beside its landings and the networks present in them.

MATCH (cable:SUBMARINE_CABLE {name: "SeaMeWe-5"})-[:CABLE_LANDS_AT]->(l:CABLE_LANDING)
MATCH (l)-[:LANDING_NEAR]->(f:FACILITY)<-[:AS_PRESENT_AT]-(a:ASN)
RETURN cable.name AS cable, l.name AS landing, f.name AS facility, a.name AS asn
LIMIT 25
SeaMeWe-5 | Karachi, Pakistan | PTCL Misri Shah DC | AS17557
SeaMeWe-5 | Karachi, Pakistan | Multinet Pakistan Karachi | AS21859
SeaMeWe-5 | Abu Talat, Egypt  | AUTO DATA Center | AS32934

measured 2026-08-10 against production

Cable names carry the registry's own spelling — SeaMeWe-5, 2Africa, FALCON, Asia Africa Europe-1 (AAE-1) — so anchor on the name as the registry writes it rather than the punctuation a press release uses.

Joined end to end, the two reads connect a web-facing hostname to the subsea cable next to its data center. The physical edges are synthesized at query time, so anchor the source label and traverse in the direction the schema documents.

The hidden link is a shared node

Two indicators that look unrelated reveal their connection the moment you find the infrastructure they share. Check the three highest-signal pivots — shared IP, shared nameserver, shared WHOIS registrant — as explicit hops. OPTIONAL MATCH keeps the row alive when a pivot is empty, which is common with redacted WHOIS data.

MATCH (a:HOSTNAME {name: "google.com"}), (b:HOSTNAME {name: "acount-google.com"})
OPTIONAL MATCH (a)-[:RESOLVES_TO]->(ip:IPV4)<-[:RESOLVES_TO]-(b)
OPTIONAL MATCH (a)<-[:NAMESERVER_FOR]-(ns:HOSTNAME)-[:NAMESERVER_FOR]->(b)
OPTIONAL MATCH (a)-[:HAS_EMAIL]->(e:EMAIL)<-[:HAS_EMAIL]-(b)
RETURN collect(DISTINCT ip.name) AS shared_ips,
       collect(DISTINCT ns.name)[0..5] AS shared_nameservers,
       collect(DISTINCT e.name) AS shared_registrant
LIMIT 1
shared_ips:         []
shared_nameservers: []
shared_registrant:  ["contact-admin@google.com"]

measured 2026-08-10 against production

Two of the three pivots are empty and the third is the finding: a lookalike of google.com and google.com itself carry the same registration contact. Read what that edge says, which is that one WHOIS record names both domains — as consistent with a brand owner buying its own typosquats defensively as with an adversary registering them. The pivot tells you where to look next, not which of the two you are in.

Explicit single hops are the reliable idiom here. Synthesized edges such as ANNOUNCED_BY and LISTED_IN do expand inside a variable-length pattern — the expansion is expensive, not broken — so bound the range and anchor an endpoint by name, or split the chain into anchored single hops joined with WITH. See Best Practices.

The choke point is the payoff

A choke point is the shared node that, severed, collapses the most paths. Expand one host to everything co-tenanted on its IP: a common IP, prefix, ASN or registrant is something you can block, sinkhole or report, and one action there does the work of many.

MATCH (h:HOSTNAME {name: "github.com"})-[:RESOLVES_TO]->(ip:IPV4)<-[:RESOLVES_TO]-(other:HOSTNAME)
WHERE other.name <> "github.com"
RETURN ip.name AS shared_ip, collect(DISTINCT other.name)[0..12] AS reachable_from_here
LIMIT 1

DNS makes a better choke point than an IP does, because a nameserver answers for a whole estate rather than for one host. Rank a target's nameservers by how many other domains each one serves, with each branch bounded in its own CALL {} block so the fan-out stays controlled.

MATCH (:HOSTNAME {name: "paypal.com"})<-[:NAMESERVER_FOR]-(ns:HOSTNAME)
WITH ns LIMIT 4
CALL { WITH ns MATCH (ns)-[:NAMESERVER_FOR]->(dep:HOSTNAME) WITH dep LIMIT 2000 RETURN count(dep) AS dependents }
CALL { WITH ns MATCH (ns)-[:NAMESERVER_FOR]->(d:HOSTNAME) WITH d LIMIT 6 RETURN collect(d.name) AS sample }
RETURN ns.name AS nameserver, dependents, sample
ORDER BY dependents DESC LIMIT 10
pdns100.ultradns.com  | 583 | paypal.ai, hellenicbank.app, paypal.com.ar, mcgraw-hill.asia, …
ns2-pchnet.paypal.com | 256 | paypal.ai, paypal.com.ar, paypal.at, paypal.com.au, …
ns1-pchnet.paypal.com | 256 | paypal.ai, paypal.com.ar, paypal.at, paypal.com.au, …
ppdns.paypal.com      | 155 | paypal.at, paypal.com.au, paypal-education.com.au, …

measured 2026-08-10 against production

The top row is not PayPal's own nameserver, and the sample shows why the ranking matters: an outsourced DNS host answering for the brand's estate also answers for names that have nothing to do with it. Read dependents as a floor rather than a census — the inner LIMIT 2000 bounds the count deliberately, so a busy nameserver reports the bound instead of its true degree.

Every node on the path carries its own evidence

A choke point is only worth acting on if you can say why. explain() returns a scored, feed-by-feed verdict for any IP, hostname, ASN or CIDR, so the node comes with a defensible reason and not a black-box number. The factors array shows the arithmetic and the sources array names each feed with its weight and first/last-seen timestamps.

CALL explain("185.220.101.1")
YIELD indicator, score, level, explanation, factors, sources
RETURN indicator, score, level, explanation, factors, sources

YIELD the columns you actually want. A bare CALL explain(...) returns the procedure's full column set, and the ones that carry nothing for this indicator come back blank beside the ones that do.

A clean verdict means the indicator is not listed at the granularity checked, not that it is safe. level: "NONE" arrives with score: 0, an empty factors array and an empty sources array — that is an absence of evidence, so read it as no-data, not benign.

Read coverage before band. Only known-clean licenses the word "clean"; no-data means unknown, which is a different thing again; malicious-evidenced and ambiguous mean there is evidence, whatever the band says. whisper.explain does not return coverage at all. Full contract: Coverage — what we looked at.

The pivots that survive IP churn

Fast-flux and bulletproof infrastructure rotate IPs faster than any feed can list them, but the TLS fingerprint often stays constant. Pivot from a foothold's serving IP to the fingerprint it emits, then to every other IP emitting the same one. Bound the fingerprint side before fanning out.

Coverage is seed-stage — 261 EMITS_TLS_FINGERPRINT edges across 765 fingerprints as of 2026-08-09, so only a few hundred IPs graph-wide carry one. Expect no match on almost any indicator.

A zero-row result here means we have no observation — not that the host shares no infrastructure.

MATCH (:HOSTNAME {name: "ec2-18-189-12-168.us-east-2.compute.amazonaws.com"})-[:RESOLVES_TO]->(ip:IPV4)-[:EMITS_TLS_FINGERPRINT]->(fp:TLS_FINGERPRINT)
WITH fp LIMIT 3
CALL { WITH fp MATCH (fp)<-[:EMITS_TLS_FINGERPRINT]-(sib:IPV4) WITH sib LIMIT 1000 RETURN count(sib) AS shared_servers }
CALL { WITH fp MATCH (fp)<-[:EMITS_TLS_FINGERPRINT]-(s:IPV4) WITH s LIMIT 8 RETURN collect(s.name) AS sample }
WITH fp, shared_servers, sample WHERE shared_servers > 0
RETURN fp.name AS fingerprint, shared_servers, sample
ORDER BY shared_servers DESC LIMIT 10

Anchoring on the serving host is what makes the pivot land: the fingerprint hangs off the address, so a hostname that only ever resolves to unobserved IPs returns nothing.

The registrant-email estate is the companion pivot, and it survives churn for a different reason — the WHOIS contact outlives the hosting.

MATCH (:HOSTNAME {name: "paypal.com"})-[:HAS_EMAIL]->(e:EMAIL)
WITH e LIMIT 3
MATCH (e)<-[:HAS_EMAIL]-(other:HOSTNAME)
RETURN e.name AS registrant_email, collect(DISTINCT other.name)[0..10] AS domains
LIMIT 10

Blast radius — pivot from one flagged IP to every co-hosted domain, the feeds that name it, and the network that routes it.

Blast radius: what breaks if this asset goes away

The choke-point queries answer what can reach this node. The inverse question is what depends on it: pick one asset and fan out everything downstream, hop by hop, following only dependency edges and never the reverse. That is an availability map, not a threat assessment, and it works on a nameserver, a mail host, an IP, a prefix or an ASN.

Start with the asset's own redundancy. Count its nameservers, mail hosts and addresses to see whether it is itself a single point of failure.

MATCH (d:HOSTNAME {name: "ns1.dreamhost.com"})
OPTIONAL MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(d)
OPTIONAL MATCH (mx:HOSTNAME)-[:MAIL_FOR]->(d)
OPTIONAL MATCH (d)-[:RESOLVES_TO]->(ip:IPV4)
RETURN count(DISTINCT ns) AS ns_count, count(DISTINCT mx) AS mx_count, count(DISTINCT ip) AS a_count
LIMIT 1

Then list the domains that lean on it for DNS. For each direct dependent, count how many nameservers it has in total: a dependent with only one is single-homed on this asset, and that dependent — not the asset — is where an outage becomes an incident.

MATCH (ns:HOSTNAME {name: "ns1.dreamhost.com"})-[:NAMESERVER_FOR]->(d:HOSTNAME)
WITH d LIMIT 40
MATCH (allns:HOSTNAME)-[:NAMESERVER_FOR]->(d)
RETURN d.name AS dependent, count(DISTINCT allns) AS total_nameservers
ORDER BY total_nameservers ASC
LIMIT 25

The same shape works for mail: a domain whose only MX is this host loses inbound mail if the host fails.

MATCH (mx:HOSTNAME {name: "ns1.dreamhost.com"})-[:MAIL_FOR]->(d:HOSTNAME)
WITH d LIMIT 40
MATCH (allmx:HOSTNAME)-[:MAIL_FOR]->(d)
RETURN d.name AS dependent, count(DISTINCT allmx) AS total_mx
ORDER BY total_mx ASC
LIMIT 25

To go deeper — the hosts on an IP, the IPs and routing ASN of a prefix, the prefixes of an ASN — run the Supply-Chain Dependency Mapping workflow. It walks the dependency chain level by level, flags single-vendor dependencies automatically, and reads the outbound direction too: what a domain itself relies on, provider by provider.

What a path does not prove

It does not name an actor. The MITRE ATT&CK knowledge base is carried as graph structure — 7,527 USES_TECHNIQUE edges from ACTOR to ATTACK_PATTERN and 872 USES_TACTIC edges, across 1,218 actors and 712 techniques. It is a curated reference layer, not Whisper's own attribution: it reflects what public reporting has mapped, not what Whisper observed. Actor names are case-sensitive and follow their canonical spelling (APT28, APT29, Sandman APT).

MATCH (a:ACTOR {name: "APT28"})-[:USES_TECHNIQUE]->(p:ATTACK_PATTERN)
RETURN a.name AS actor, collect(DISTINCT p.name)[0..12] AS techniques
LIMIT 1

That returns a technique rollup, and a rollup is all it returns. Production holds 4 ATTRIBUTED_TO edges (2026-08-09) against 1,218 actors, so there is no traversable join from an actor to live infrastructure. The techniques tell you which steps to go looking for; everything else on this page traces infrastructure, and none of it comes back to the actor. A route is not an identification.

Sharing a node is not sharing an operator. Two names on one IP, one nameserver or one registrant email are related by a record, not by intent, and the acount-google.com result above is the case that proves it. Shared hosting and managed DNS put unrelated parties on the same node by design.

Zero rows is not a negative finding. A refusal is an error: if the engine will not run a query it says so, with the reason in the body. So zero rows means either your labels or edge names are wrong, or Whisper genuinely has no observation. CALL db.labels() and CALL db.relationshipTypes() settle the first case cheaply. If the query is right, the absence is real — and an absence is not a clean verdict.

When an internal attack-path tool is the right tool

The tools that do attack-path analysis today — BloodHound, XM Cyber, Cymulate, the cloud IAM analyzers — model the inside of one organization, and that is the right model when the question is inside one: privilege escalation through Active Directory, lateral movement across hosts, permission chains in one tenant. Use this graph when the path runs between organizations and across the public internet: tracing an adversary's infrastructure, connecting two indicators, or finding the shared node that ties a campaign together. They are complementary. Internal tools own the perimeter inward; this one owns the perimeter outward.

Working the path

  • Anchor every query on an indexed {name: "value"}. HOSTNAME and IPV4 are too large to scan; an unanchored walk does not finish.
  • shortestPath requires a bounded length. Always bound the range, for example [*1..6]. An unbounded variable-length pattern will not finish on a billion-node label.
  • Synthesized edges are expensive inside a [*..] pattern, not broken. ANNOUNCED_BY, ROUTES and LISTED_IN are computed at query time, so a variable-length walk over them expands the virtual layer as it goes. Bound the range and anchor an endpoint by name, or split them into explicit single hops joined with WITH.
  • Bound high-fan-out intermediates with WITH … LIMIT before expanding, and give each branch its own CALL {} block, so a choke-point query does not explode. A single shared-hosting or CDN IP can answer for hundreds of thousands of names.

The full list is on Best Practices. Every query here runs against POST /api/query with your key in the X-API-Key header; if you do not have one, sign in and copy it from the console. A long chain is often better written as a procedure call anyway — explain() collapses a whole threat traversal into one.