# Campaign Pivoting

> Pivot one indicator into the whole campaign: co-tenancy, WHOIS registrant, nameserver, TLS fingerprint and CT pivots, and actor and ATT&CK lookups.

*Source: https://www.whisper.security/docs/recipes/threat-intel*
*Published: 2026-05-04*
*Last updated: 2026-10-01*

---
You hold one indicator — a phishing domain, a C2 IP, a suspicious nameserver. These recipes take you to the rest of the campaign: every sibling domain, the shared registrant folded to one company, the co-tenant hosts, the operator's infrastructure habits, the actor public reporting names, and how it all moved over time. WhisperGraph pre-joins DNS, WHOIS/RDAP, BGP, threat verdicts, Tor/TLS egress, Certificate Transparency and the ATT&CK knowledge base into one surface, so each pivot is a single hop and the whole campaign falls out of one traversal.

Every recipe below is copy-paste against the Cypher/REST endpoint at `https://graph.whisper.security/api/query`. New to the graph? Start with [Getting Started](/docs/whisper-graph/getting-started), keep the [Graph Schema](/docs/whisper-graph/schema) and [Procedures](/docs/whisper-graph/procedures) open, and pull more patterns from [Cross-Layer Patterns](/docs/recipes/cross-cutting).

> **Run it live:** [Investigate an Indicator](/products/intelligence/use-cases/threat-investigation/indicator) · [Digital Infrastructure Mapping](/products/intelligence/use-cases/infrastructure-supply-chain/infrastructure-mapping) · [Build the takedown evidence package](/products/intelligence/use-cases/brand-protection/build-takedown-evidence-package) — each opens with a live result you can rerun on your own indicator.

**Key concepts:** [Co-hosted domains](/glossary/co-hosted-domains) · [Infrastructure pivoting](/glossary/infrastructure-pivoting) · [Passive DNS](/glossary/passive-dns) · [C2 infrastructure](/glossary/c2-infrastructure) · [Bulletproof hosting](/glossary/bulletproof-hosting) · [MITRE ATT&CK](/glossary/mitre-attack).

## Quick triage

For a full triage workflow (verdict, feeds, posture, escalation), see [Indicator Triage (SOC)](/docs/recipes/soc): open on the [reconciled verdict](/docs/recipes/soc#reconciled-verdict-one-blocking-aware-answer), the one blocking-aware answer that decides whether this indicator is worth pivoting on at all. The read below is the minimum you need on top of it.

> **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.
> Full contract: [Coverage — what we looked at](/docs/whisper-graph/procedures/coverage).

### Get the scored evidence chain for any indicator

**What the graph does:** `explain` returns the arithmetic — every contributing feed, its weight, and first/last-seen — for an IP, IPv6, hostname, ASN, or CIDR.

```cypher expect=rows>0 seed=paypal-account-verify.com verified=2026-09-02
CALL explain("paypal-account-verify.com")
YIELD indicator, type, found, score, level, explanation, factors, sources
RETURN indicator, type, found, score, level, explanation, factors, sources
```

> **Tip:** `explain` works on a whole ASN or CIDR too — `CALL explain("AS_number")` quantifies a malicious neighborhood. A clean result means "not listed", not "safe": no data is not the same as known clean.

## Pivot one indicator into the whole campaign

![Diagram: a flagged IPV4 address at the centre. Two HOSTNAMEs RESOLVES_TO it, so they are co-hosted. The address is LISTED_IN two FEED_SOURCEs and ANNOUNCED_BY an ANNOUNCED_PREFIX that an ASN ROUTES; the ASN carries the network's abuse desk. One co-hosted HOSTNAME HAS_EMAIL a registrant EMAIL, and a sibling HOSTNAME with the same EMAIL is one no feed has flagged yet. Documentation-range values.](/media/orXfAzhmu6HF9ADG_whisper-campaign-pivot.svg "From one flagged address, what else does the campaign touch?")

### Co-tenancy: every domain on the same IP

Try it on a live IP — every hostname currently resolving to it:

```whisper-run expect=rows>0 seed=140.82.121.3 verified=2026-09-02
MATCH (ip:IPV4 {name: "140.82.121.3"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN h.name AS hostname
LIMIT 15
```

**What the graph does:** one hop in, one hop back out. `RESOLVES_TO` is `HOSTNAME → IP`, so the sibling hosts hang off the reverse arrow.

```cypher expect=rows>0,no-null-columns seed=payapl.com verified=2026-09-02
// Domains co-hosted with a suspicious host on the same IP
MATCH (seed:HOSTNAME {name: "payapl.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH seed, ip LIMIT 10
MATCH (ip)<-[:RESOLVES_TO]-(sibling:HOSTNAME)
WHERE sibling <> seed
RETURN ip.name AS shared_ip, sibling.name AS co_tenant
LIMIT 25
```

> **Tip:** one shared-hosting IP can carry thousands of unrelated tenants, so the `WITH ... LIMIT 10` bounds the IP fan-out first. Co-tenancy on a big provider doesn't by itself imply a relationship — treat it as a lead, then confirm with a registrant or nameserver pivot, or measure how many siblings under the *same apex* share the address ([How many siblings under the same apex share this IP?](#how-many-siblings-under-the-same-apex-share-this-ip)). If the seed no longer resolves, pull [`CALL whisper.history.whois(...)`](/docs/whisper-graph/procedures/history) for what it used to be.

### Registrant pivot: every domain sharing a WHOIS email

**What the graph does:** WHOIS is per-domain, but the registrant email is a first-class node. Pivot through it to the actor's whole portfolio. `HAS_EMAIL` is `HOSTNAME → EMAIL`, so the siblings are on the reverse arrow.

```cypher expect=rows>0 seed=cloudflare.com verified=2026-09-02
// All domains registered with the same WHOIS contact email
MATCH (seed:HOSTNAME {name: "cloudflare.com"})-[:HAS_EMAIL]->(e:EMAIL)
WITH seed, e LIMIT 5
MATCH (e)<-[:HAS_EMAIL]-(sibling:HOSTNAME)
WHERE sibling <> seed
RETURN e.name AS shared_email, sibling.name AS related_domain
LIMIT 25
```

> **Tip:** registrar privacy services replace the real registrant with a proxy address (`domains@cloudflare.com`, `contact@privacyguardian.org`). A proxy email clusters by *registrar*, not by actor — confirm the email isn't a privacy service before drawing attribution conclusions. To keep only the siblings nothing has flagged yet, add `AND coalesce(sibling.isThreat, false) = false` to the `WHERE`: that inverts the pivot from confirming what you know into surfacing what nobody has judged.

### Which company is really behind a messy registrant string?

Registrant organisation strings come out of WHOIS exactly as the registrar stored them: truncated, lower-cased, comma-terminated, spelled three ways for one company. Clustering on the raw string splits one owner into several. The graph carries a canonical link that folds the variants together, so a portfolio view holds.

```cypher expect=rows>0 seed=github.com verified=2026-09-02
// The raw registrant string, and the company it actually is
MATCH (h:HOSTNAME {name: "github.com"})-[:REGISTERED_BY]->(o:ORGANIZATION)
OPTIONAL MATCH (o)-[:SAME_ORG_AS]->(c:ORGANIZATION)
RETURN o.name AS raw_registrant, collect(DISTINCT c.name) AS canonical
LIMIT 5
```
**Returns:** `raw_registrant, canonical`

**Sample output** (captured 2026-09-02):
```json
[
  {"raw_registrant": "github hostmaster", "canonical": ["GitHub, Inc."]},
  {"raw_registrant": "github,", "canonical": ["GitHub, Inc."]}
]
```

**Costs:** one anchored hop plus an optional canonical hop; most organisation strings have no canonical twin, so the second hop must stay `OPTIONAL` or the raw value disappears with it; an empty `canonical` list means the string is already clean or simply unmapped, never that the owner is unknown.

> **Tip:** cluster on the canonical name and keep the raw string as evidence — it is the difference between "four registrants" and "one company, four spellings". The same edge works from the address side, so an IP range resolves to its registered holder rather than to whatever string the RIR record carried: `MATCH (ip:IPV4 {name: "1.1.1.1"})-[:BELONGS_TO]->(:REGISTERED_PREFIX)-[:REGISTERED_BY]->(o:ORGANIZATION) OPTIONAL MATCH (o)-[:SAME_ORG_AS]->(c:ORGANIZATION)`.

**From here, →** [Registrant pivot: every domain sharing a WHOIS email](#registrant-pivot-every-domain-sharing-a-whois-email) for the contact-level pivot the organisation confirms.

### Shared-nameserver siblings

**What the graph does:** `NAMESERVER_FOR` points **server → domain**, so a custom nameserver fans straight out to its whole client list — a strong clustering signal when the actor runs their own DNS.

```cypher expect=rows>0,no-null-columns seed=ns1.dsredirection.com verified=2026-09-02
// Every domain delegating DNS to a specific nameserver
MATCH (ns:HOSTNAME {name: "ns1.dsredirection.com"})-[:NAMESERVER_FOR]->(domain:HOSTNAME)
RETURN domain.name AS delegated_domain
LIMIT 25
```

> **Tip:** filter out the giants. Domains on `ns1.google.com` or Cloudflare's nameservers tell you nothing, and a parking or redirection service like the one seeded above clusters by *service*, not by actor — it is a starting shape, not a finding. A private or oddly-named nameserver shared across a handful of suspicious domains is the find. When you start from a domain rather than a nameserver, bound both sides before you aggregate: `WITH ns LIMIT 2` after the reverse hop, then `WITH ns, sib LIMIT 200` before the `collect`. A trailing `LIMIT` on the `RETURN` does not save you — the engine still walks every domain on the nameserver first.

### One query, three pivots: the campaign in a single traversal

**What the graph does:** co-tenancy, shared registrant, and shared nameserver are three edge types on the same node. `UNION` them into one campaign view, tagged by pivot type.

```cypher expect=rows>0 seed=paypal-account-verify.com verified=2026-09-02
// Blast radius: union co-tenants, registrant siblings, and NS siblings
MATCH (seed:HOSTNAME {name: "paypal-account-verify.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH seed, ip LIMIT 10
MATCH (ip)<-[:RESOLVES_TO]-(s:HOSTNAME) WHERE s <> seed
RETURN "co-tenant IP" AS pivot, s.name AS related, ip.name AS via
LIMIT 25
UNION
MATCH (seed:HOSTNAME {name: "paypal-account-verify.com"})-[:HAS_EMAIL]->(e:EMAIL)
WITH seed, e LIMIT 5
MATCH (e)<-[:HAS_EMAIL]-(s:HOSTNAME) WHERE s <> seed
RETURN "registrant email" AS pivot, s.name AS related, e.name AS via
LIMIT 25
UNION
MATCH (seed:HOSTNAME {name: "paypal-account-verify.com"})<-[:NAMESERVER_FOR]-(ns:HOSTNAME)
WITH ns LIMIT 5
MATCH (ns)-[:NAMESERVER_FOR]->(s:HOSTNAME)
WHERE s.name <> "paypal-account-verify.com"
RETURN "shared nameserver" AS pivot, s.name AS related, ns.name AS via
LIMIT 25
```

> **Tip:** the `pivot` column tells your analyst *why* each host joined the cluster — co-tenancy is the weakest signal (shared hosting), shared registrant and private nameserver are stronger. Feed the `related` list back through `explain()` to rank the cluster by verdict. For a list you already hold, run the clustering the other way: `UNWIND` the names, match each to its `EMAIL`, `WITH e.name AS registrant, collect(DISTINCT h.name) AS members WHERE size(members) > 1` — the `size` filter drops every registrant that explains only one domain and leaves just the links.

## Typosquats & lookalikes

Lookalike domains are pivot seeds like any other: generate the registered set with [the typosquat recipe](/docs/recipes/soc#catch-the-lookalike-domain-behind-the-lure), then run each hit back through the co-tenancy, registrant and nameserver pivots above. `exists: true` means *registered*, not *malicious* — a parked typo and an active phishing kit look identical at that stage, so take the verdict from `explain()` first and cluster only the flagged ones. For the full brand playbook (anchored prefix sweeps, TLD sweeps, parked-vs-weaponized, phishing-kit fingerprints, takedown evidence), see [Lookalike Hunting](/docs/recipes/brand-protection).

## Actor & ATT&CK layer

> WhisperGraph carries the MITRE ATT&CK knowledge base as graph structure — 9,630 `USES_TECHNIQUE` edges from `ACTOR` to `ATTACK_PATTERN` and 881 `USES_TACTIC` edges, across 1,986 actors and 721 techniques. **This is a curated reference layer, not Whisper's own attribution.** It reflects what public reporting has mapped, not what Whisper observed. Two edges reach from it to live infrastructure, and both are sparse by design: `TAGGED_AS` (138,972 edges) is a malware-family or campaign label, and `ATTRIBUTED_TO` (345 edges) is a published attribution to a named group. An empty result on either means nobody has published one, never that the indicator is unattributed in the world.

The `ACTOR` and `ATTACK_PATTERN` nodes sit in the same graph as the infrastructure you just mapped, so a campaign cluster reaches them in one hop — as a lead about the reporting, never as a name on the infrastructure. Actor names and alias values are exact-match and case-sensitive, so resolve the name first and pivot second.

### Which actor is behind this alias?

The report on your desk says "Leviathan", your feed says "Kryptonite Panda", a colleague says "APT40". They are one group, and a name search on any one of them finds only that one. Search the alias set instead, then pivot on whichever canonical name comes back.

```cypher expect=rows>0 seed=Leviathan verified=2026-09-02
// Which canonical actor does this alias belong to?
MATCH (a:ACTOR)
WHERE "Leviathan" IN a.aliases
RETURN DISTINCT a.name AS canonical_actor, a.aliases AS aliases
LIMIT 5
```
**Returns:** `canonical_actor, aliases`

**Sample output** (captured 2026-09-02):
```json
[
  {"canonical_actor": "APT40", "aliases": ["TEMP.Periscope", "TEMP.Jumper", "Leviathan", "BRONZE MOHAWK", "GADOLINIUM", "KRYPTONITE PANDA", "G0065", "ATK29", "TA423", "Red Ladon", "ITG09", "MUDCARP", "ISLANDDREAMS", "Gingham Typhoon", "ISLAND CASTLE"]},
  {"canonical_actor": "Leviathan", "aliases": ["Leviathan", "MUDCARP", "Kryptonite Panda", "Gadolinium", "BRONZE MOHAWK", "TEMP.Jumper", "APT40", "TEMP.Periscope", "Gingham Typhoon"]}
]
```

**Costs:** a filter over a small catalogue with no traversal; the alias value is matched exactly (`"Leviathan"` finds it, `"leviathan"` does not); more than one node can legitimately answer, because different sources canonicalise the same group under different primary names.

> **Tip:** take the aliases from whichever row you match and search on the widest set rather than assuming the first row is authoritative. When you only have a partial name, `MATCH (a:ACTOR) WHERE a.name STARTS WITH "Lazarus" RETURN a.name` is indexed and cheap, and tells you the exact capitalisation the graph uses. If a name comes back more than once, use the row whose `description` matches the group you mean.

**From here, →** [What techniques does a named actor use?](#what-techniques-does-a-named-actor-use)

### What techniques does a named actor use?

You are profiling an adversary and want its technique set from ATT&CK in one query, ready for a report or a detection-coverage review. The set tells you which of your controls the group is known to test.

```cypher expect=rows>0 seed=APT28 verified=2026-09-02
// An actor's MITRE ATT&CK techniques
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
```
**Returns:** `actor, techniques`

**Sample output** (captured 2026-09-02):
```json
[{"actor": "APT28", "techniques": ["Junk Data", "OS Credential Dumping", "LSASS Memory", "NTDS", "Data from Local System", "Rootkit", "SMB/Windows Admin Shares", "Data from Removable Media", "Encrypted/Encoded File", "Data Transfer Size Limits", "Masquerading", "Match Legitimate Resource Name or Location"]}]
```

**Costs:** one indexed anchor and one hop, bounded by the slice; actor names are case-sensitive in their canonical spelling (`APT28`, `Sandman APT`), so an empty result usually means the name, not the absence of the actor.

> **Tip:** the label is `:ATTACK_PATTERN`, never `:TECHNIQUE` — one label carries techniques and tactics, separated by `kind`. Anchor a single technique by its T-number and climb to the tactic it serves: `MATCH (t:ATTACK_PATTERN {name: "T1003"})-[:USES_TACTIC]->(tac:ATTACK_PATTERN) RETURN t.name, tac.id, tac.name`. The name index knows each technique by its T-number as well as its readable name, so `{name: "T1003"}` is an indexed lookup even though `t.name` returns "OS Credential Dumping" and the T-number itself is `t.id`; `{id: "T1003"}` returns the same row by scanning every attack pattern.

**From here, →** [Which other actors share a technique?](#which-other-actors-share-a-technique) to widen the candidate set, or [Which tactic vocabulary is the graph built on?](#which-tactic-vocabulary-is-the-graph-built-on) before you map report language onto it.

### Which tactic vocabulary is the graph built on?

You are mapping report language onto ATT&CK. The framework renumbers and renames tactics between releases, and a mapping written against last year's names will not join cleanly. Read the vocabulary the graph speaks before you write the mapping.

```cypher expect=rows>0 verified=2026-09-02
// The tactic vocabulary this graph is built on
MATCH (t:ATTACK_PATTERN {kind: "tactic"})
RETURN t.id AS tactic_id, t.name AS tactic
ORDER BY tactic_id
LIMIT 20
```
**Returns:** `tactic_id, tactic`

**Sample output** (captured 2026-09-02):
```json
[
  {"tactic_id": "TA0001", "tactic": "Initial Access"},
  {"tactic_id": "TA0002", "tactic": "Execution"},
  {"tactic_id": "TA0003", "tactic": "Persistence"}
]
```

**Costs:** a filter over a small catalogue, no traversal; the set is short enough to read whole, so the `LIMIT` is a formality rather than a bound.

> **Tip:** watch for `TA0005` reading **Stealth** alongside `TA0112` **Defense Impairment** — that pairing tells you the graph is built on the release that split the old Defense Evasion tactic in two. Detection content that still maps to "Defense Evasion" now lands on one of those two.

**From here, →** [What techniques does a named actor use?](#what-techniques-does-a-named-actor-use)

### Which other actors share a technique?

A technique stood out in your investigation and you want to know who else is known to use it. On a rare technique this widens the candidate set behind an intrusion; on a common one it tells you the technique will not narrow anything.

```cypher expect=rows>0 seed=APT28 verified=2026-09-02
// Pivot from an actor's techniques to other actors using them
MATCH (a:ACTOR {name: "APT28"})-[:USES_TECHNIQUE]->(p:ATTACK_PATTERN)
WITH p LIMIT 3
MATCH (p)<-[:USES_TECHNIQUE]-(other:ACTOR)
RETURN p.name AS technique, collect(DISTINCT other.name)[0..8] AS actors
LIMIT 5
```
**Returns:** `technique, actors`

**Sample output** (captured 2026-09-02):
```json
[
  {"technique": "Junk Data", "actors": ["APT28"]},
  {"technique": "OS Credential Dumping", "actors": ["Sowbug", "APT39", "APT32", "APT28", "Suckfly", "BlackByte", "Tonto Team", "Leviathan"]}
]
```

**Costs:** one anchored hop out, one bounded hop back; the `WITH p LIMIT 3` bounds the technique side, because credential dumping and discovery are shared by dozens of actors and would fan out without it.

> **Tip:** the pivot earns its keep on rarer, more distinctive techniques — favour the unusual ones. A technique that returns only the actor you started from (as `Junk Data` does above) is a distinctive fingerprint of that group; one that returns eight is background noise.

**From here, →** [Which actor is behind this alias?](#which-actor-is-behind-this-alias) to fold the returned names onto their canonical groups.

### Which malware family is this host tagged with, and what else carries the tag?

You hold a domain from an alert and want the other direction: not what an actor uses, but which family this infrastructure is associated with and which other hosts share the label. A shared tag is a strong clustering signal, so this is where a campaign map starts.

```cypher expect=rows>0 seed=command-center.cc verified=2026-09-26
// Malware-family tag on a host, and the other hosts carrying the same tag
MATCH (n:HOSTNAME {name: "command-center.cc"})-[:TAGGED_AS]->(t:THREAT_TAG)
WITH n, t LIMIT 5
MATCH (t)<-[:TAGGED_AS]-(sib)
WHERE sib <> n
WITH n, t, sib LIMIT 200
RETURN t.name AS tag, n.verdictLevel AS verdict,
       collect(DISTINCT sib.name)[0..10] AS same_tag, count(sib) AS shown
LIMIT 5
```
**Returns:** `tag, verdict, same_tag, shown`

**Sample output** (captured 2026-09-26):
```json
[{"tag": "win.count_loader", "verdict": "CRITICAL", "same_tag": ["azure-s3-bucket.cc", "communicationfirewall-security.cc", "fileless-market.cc", "fileless-storage-s3.cc", "files-storage.cc", "filezilla.cc", "holiday-forever.cc", "memory-protection-layer1.cc", "memory-protection-layer2.cc", "network-defender.cc"], "shown": 22}]
```

The sibling names are worth a second look: they sit on the same `.cc` suffix and imitate cloud storage, file transfer and security products. A family tag plus a shared naming pattern is a stronger cluster than either signal alone.

**Costs:** one anchored hop to the tag and one bounded hop back; both `WITH ... LIMIT`s sit before the aggregation so a widely used tag cannot run away; `shown` tells you whether the sibling set hit the bound.

Empty result: `TAGGED_AS` is a coverage-scoped layer. Zero rows means no source has labelled this host with a family, never that it belongs to none. Tag values carry their namespace (`win.phorpiex`, `misp:...`), so match on the full string when you anchor on a tag instead of a host. To find live examples to work from, drop the anchor: `MATCH (n:HOSTNAME)-[:TAGGED_AS]->(t:THREAT_TAG) RETURN n.name, t.name LIMIT 15`.

> **Tip:** a tag is a *family* association, not an attribution — two hosts sharing `win.phorpiex` are associated with the same malware label, which is a reason to cluster them, not a name on the operator. Run the `same_tag` list through the co-tenancy and registrant pivots above to see whether the infrastructure agrees with the label.

**From here, →** [Has this indicator been attributed to a named actor?](#has-this-indicator-been-attributed-to-a-named-actor)

### Has this indicator been attributed to a named actor?

Attribution to a named group runs over its own edge, separate from the family tag, and it is the claim a reader will challenge. Read it as a published attribution you can cite, with its source, and never infer it from a tag or a technique match.

```cypher expect=rows>0 seed=194.87.93.153 verified=2026-09-02
// Named-adversary attribution carried on the indicator
MATCH (n:IPV4 {name: "194.87.93.153"})-[:ATTRIBUTED_TO]->(a:ACTOR)
RETURN n.name AS indicator, a.name AS actor, n.verdictLevel AS verdict
LIMIT 5
```
**Returns:** `indicator, actor, verdict`

**Sample output** (captured 2026-09-02):
```json
[{"indicator": "194.87.93.153", "actor": "Head Mare", "verdict": "NONE"}]
```

**Costs:** one indexed anchor and one hop over a very sparse edge; the same shape runs from the actor down to its infrastructure (`MATCH (n)-[:ATTRIBUTED_TO]->(a:ACTOR {name: "Head Mare"}) RETURN labels(n)[0], n.name`).

Empty result: `ATTRIBUTED_TO` is deliberately sparse (345 edges). Zero rows means nobody has published an attribution for this indicator, never that it is unattributed in the world, and never that it is clean — the verdict is a separate read. To see what is attributed right now, drop the anchor: `MATCH (n)-[:ATTRIBUTED_TO]->(a:ACTOR) RETURN labels(n)[0], n.name, a.name LIMIT 15`.

> **Tip:** notice the `verdict` column reads `NONE` on an attributed address — attribution and reputation are different claims from different sources, and they routinely disagree. Cite the attribution, then run `explain()` for the feed evidence, and let the report carry both.

**From here, →** [What techniques does a named actor use?](#what-techniques-does-a-named-actor-use) once you have a canonical name to profile.

## Egress & fingerprint pivots

### Identify Tor-exit infrastructure that survives IP rotation

**What the graph does:** an exit node's IP changes; the relay identity (its fingerprint) doesn't. `OPERATES_EXIT_NODE` ties an IP to that stable `TOR_RELAY` identity, so you can confirm an IP is a Tor exit and recover the relay behind it.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
// Is this IP a Tor exit, and which relay identity operates it?
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:OPERATES_EXIT_NODE]->(relay:TOR_RELAY)
RETURN ip.name AS ip, ip.isTor AS flagged_tor, relay.name AS relay_fingerprint
LIMIT 10
```

> **Tip:** Tor egress isn't malicious by itself, but it changes how you weight other signals. Cross-check `ip.isAnonymizer` and `ip.isProxy` on the same node.

### Track C2 across changing domains with a TLS fingerprint

**What the graph does:** when an actor rotates domains and IPs, the only stable thread is how the server *speaks TLS*. `EMITS_TLS_FINGERPRINT` ties an IP to that JA3/JARM fingerprint. Find the fingerprint your seed C2 emits, then pivot to every other IP emitting the same one.

```cypher expect=rows>0,no-null-columns seed=144.217.207.19 verified=2026-09-02
// Other IPs emitting the same JA3/JARM fingerprint as a known C2 IP
MATCH (seed:IPV4 {name: "144.217.207.19"})-[:EMITS_TLS_FINGERPRINT]->(fp:TLS_FINGERPRINT)
WITH fp LIMIT 5
MATCH (fp)<-[:EMITS_TLS_FINGERPRINT]-(other:IPV4)
WHERE other.name <> "144.217.207.19"
RETURN fp.name AS fingerprint, other.name AS same_stack_ip, other.verdictLevel AS level
LIMIT 25
```

Empty result: TLS-fingerprint coverage is partial, so expect most indicators to return nothing. **A zero-row result here means Whisper holds no observation — not that the host shares no infrastructure.**

> **Tip:** common server stacks share fingerprints across millions of hosts, so the pivot is only meaningful when the JARM is distinctive — typically a bespoke C2 framework. Rank hits by `verdictLevel` to surface the ones already flagged. Fingerprint names carry their scheme as a prefix (`jarm:`, `ja3:`); to learn what a hash *is* before you cluster on it, `CALL whisper.lookupTlsFingerprint("<hash>")` names the client build behind common ones.

### Discover subdomains and SANs from Certificate Transparency

**What the graph does:** an actor's staging subdomains may never resolve publicly, but they leak into CT logs the moment a cert is issued. `SEEN_IN_CT` connects a host to its Certificate Transparency observations, surfacing names that DNS alone would miss. A wildcard observation is the loudest of these: it proves a cert covers subdomains the actor never had to publish.

```cypher expect=static seed=koinbase.com verified=2026-09-03 reason="certificate-transparency observations depend on when a certificate was logged, so this block shows a captured result rather than a live run"
// CT observations for a domain — surfaces SANs / staging subdomains
MATCH (h:HOSTNAME {name: "koinbase.com"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.name AS ct_observation,
       ct.wildcard AS covers_subdomains,
       ct.certCount AS certificates
LIMIT 25
```

**Sample output** (captured 2026-09-03):
```json
[
  {"ct_observation": "*.koinbase.com", "covers_subdomains": true, "certificates": 2},
  {"ct_observation": "koinbase.com", "covers_subdomains": false, "certificates": 2}
]
```

Empty result: Certificate Transparency coverage is partial. `github.com` has none. `paypal.com` has none. **A zero-row result here means Whisper holds no CT observation for that host. It never means the host has a clean certificate history.** If certificate history is load-bearing for your decision, query a CT log directly — crt.sh or the Google CT API — and come back with the hostnames you find.

> **Tip:** CT discovery pairs well with the campaign pivots above — a SAN found here is a new seed host. Run it back through the co-tenancy and registrant pivots to extend the cluster.

### Separate the operator from the WHOIS owner

**What the graph does:** WHOIS says who *owns* a netblock; it rarely says who *operates* it, and attackers abuse the gap. `DELEGATED_TO` records the vendor actually running an address, distinct from the registrant `ORGANIZATION`, and it hangs off the address itself — no detour through the prefix is needed.

```cypher expect=rows>0,no-null-columns seed=52.33.207.7 verified=2026-09-28
// Which vendor operates this address? (one of the addresses a registered paypal.com lookalike resolves to)
MATCH (ip:IPV4 {name: "52.33.207.7"})-[:DELEGATED_TO]->(v:VENDOR)
RETURN ip.name AS ip, v.name AS operated_by
LIMIT 10
```

> **Tip:** `VENDOR` names are lowercase slugs (`aws`, `azure`, `cloudflare`, `sendgrid`), and an address with no delegation on file returns no row — that is "unknown operator", not "self-operated". A "consumer" address delegated to a cloud vendor, or the reverse, is a mismatch worth a second look. Combine with the host → network-owner walk below for the full ownership picture.

## Infrastructure ownership & identity

### Who hosts this domain, on whose network

**What the graph does:** host → IP → prefix → ASN → network name → country is one traversal. `ANNOUNCED_BY` covers the IP, `ROUTES` reaches the ASN, `HAS_NAME` resolves the network name, and `LOCATED_IN`/`HAS_COUNTRY` geolocate it.

```cypher expect=rows>0 seed=payapl.com verified=2026-09-02
// Domain → IP → announced prefix → ASN → network name, plus country
MATCH (h:HOSTNAME {name: "payapl.com"})-[:RESOLVES_TO]->(ip:IPV4)
WITH h, ip LIMIT 5
MATCH (ip)-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)-[:ROUTES]->(a:ASN)-[:HAS_NAME]->(n:ASN_NAME)
OPTIONAL MATCH (ip)-[:LOCATED_IN]->(city:CITY)-[:HAS_COUNTRY]->(c:COUNTRY)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network, c.name AS country
LIMIT 10
```

> **Tip:** `RESOLVES_TO` and `ANNOUNCED_BY` are forward edges (`HOSTNAME → IP`, `IP → prefix`); `ROUTES` matches from either direction. The ASN's number lives on `a.name` (`AS13335`); the human-readable network name lives on the separate `ASN_NAME` node, and the registrant company on `(a)-[:REGISTERED_BY]->(:ORGANIZATION)` — reverse that hop to enumerate every network registered to the same company. `country` comes from an `OPTIONAL MATCH`, so it is blank for any IP with no city-level geolocation on file. Hosting identity is a separate question from the threat verdict — answer the second with `explain()`.

## Infrastructure signals

A verdict says *how bad*; a signal says *what kind of setup*. The graph attaches curated, infrastructure-level signals through `HAS_SIGNAL` to three kinds of node, each with its own set: prefixes carry signals such as `toxic-neighborhood` and `prefix-age-anomaly`, autonomous systems such as `bulletproof-hosting`, `critical-infrastructure`, `ddos-mitigation` and `satellite-network`, and hostnames such as `hidden-primary-soa`, `subdomain-takeover` and `wildcard-dns`. Anchor on the label that carries the signal you want; anchoring on the wrong one is the usual reason for an empty result. `MATCH (n)-[:HAS_SIGNAL]->(s:THREAT_SIGNAL_TYPE) RETURN s.name, labels(n)[0], count(*) ORDER BY count(*) DESC LIMIT 20` is the census, and it survives the catalogue changing under you.

### Which networks does the graph flag as bulletproof hosting?

You keep finding the same operators behind unrelated campaigns. Rather than rediscover them one indicator at a time, pull the list the graph itself characterises as bulletproof hosting, ranked by how much of the internet sits downstream of each, and decide which belong on a watch list.

```cypher expect=static verified=2026-09-03 reason="signal assignments are recomputed as the underlying evidence changes, so this block shows a captured result rather than a live run"
// Networks the graph flags as bulletproof hosting, largest customer cone first
MATCH (a:ASN)-[:HAS_SIGNAL]->(:THREAT_SIGNAL_TYPE {name: "bulletproof-hosting"})
RETURN a.name AS asn, a.asRank AS as_rank, a.coneAsns AS customer_cone
ORDER BY a.coneAsns DESC
LIMIT 15
```
**Returns:** `asn, as_rank, customer_cone`

**Sample output** (captured 2026-09-02):
```json
[
  {"asn": "AS49581", "as_rank": 441, "customer_cone": 105},
  {"asn": "AS31561", "as_rank": 450, "customer_cone": 102},
  {"asn": "AS56584", "as_rank": 654, "customer_cone": 68}
]
```

**Costs:** one hop from a small signal catalogue back to the networks that carry it, sorted on a node property; no indicator anchor is needed, because the signal node is the anchor.

> **Tip:** `coneAsns` counts the autonomous systems reachable through this one as a provider, so it is a rough measure of how much the operator carries for other people. A bulletproof-flagged network with a hundred ASNs in its cone is reselling, which means the abuse you find there is probably several tenants deep. Use the list as a watch list rather than a block list: the signal characterises the operator, not any individual customer.

**From here, →** [What address space does a bulletproof network route?](#what-address-space-does-a-bulletproof-network-route)

### What address space does a bulletproof network route?

You have a flagged operator and want its actual address blocks — the ranges to watch, hunt through, or hand to a detection engineer. The prefix pattern itself is often the tell.

```cypher expect=static verified=2026-09-03 reason="signal assignments are recomputed as the underlying evidence changes, so this block shows a captured result rather than a live run"
// From a bulletproof-hosting ASN to the prefixes it routes
MATCH (a:ASN)-[:HAS_SIGNAL]->(:THREAT_SIGNAL_TYPE {name: "bulletproof-hosting"})
WITH a LIMIT 3
MATCH (a)-[:ROUTES]->(p:ANNOUNCED_PREFIX)
RETURN a.name AS asn, collect(p.name)[0..8] AS sample_prefixes
LIMIT 5
```
**Returns:** `asn, sample_prefixes`

**Sample output** (captured 2026-09-02):
```json
[
  {"asn": "AS197170", "sample_prefixes": ["45.153.34.0/24", "45.156.87.0/24", "85.11.167.0/24", "87.121.84.0/24", "91.92.40.0/24", "91.92.42.0/24", "91.92.47.0/24", "93.152.221.0/24"]},
  {"asn": "AS197769", "sample_prefixes": ["31.57.184.0/24", "31.57.216.0/24", "91.231.222.0/24", "102.220.160.0/22", "102.220.163.0/24", "130.12.181.0/24", "130.12.182.0/24"]}
]
```

**Costs:** one hop to the flagged networks, bounded to three before the expansion, then one `ROUTES` hop per network; the `WITH a LIMIT 3` is not optional, because a single operator can route thousands of blocks and the slice only applies after the collect.

> **Tip:** long runs of adjacent `/24`s carved out of one allocation is what churn-and-burn hosting looks like from the routing table. Feed a block into the neighbourhood read on the SOC page ([Neighborhood toxicity](/docs/recipes/soc#neighborhood-toxicity-threat-density-per-prefix)) to see how much of it is already listed, or anchor a specific network by name and drop the signal match.

**From here, →** [Who hosts this domain, on whose network](#who-hosts-this-domain-on-whose-network) to check whether a suspect resolves into one of these blocks.

### Which hosts are flagged for wildcard DNS?

A domain answering on every possible subdomain is cheap infrastructure for whoever generates names faster than a feed can list them. The graph flags hosts for exactly that behaviour, and the flag is useful precisely where reputation data is silent.

```cypher expect=static verified=2026-09-03 reason="signal assignments are recomputed as the underlying evidence changes, so this block shows a captured result rather than a live run"
// Hostnames flagged for wildcard-DNS behaviour
MATCH (h:HOSTNAME)-[:HAS_SIGNAL]->(:THREAT_SIGNAL_TYPE {name: "wildcard-dns"})
RETURN h.name AS host, h.verdictLevel AS verdict, h.verdictCoverage AS coverage
LIMIT 20
```
**Returns:** `host, verdict, coverage`

**Sample output** (captured 2026-09-02):
```json
[
  {"host": "bloomstudio.app", "verdict": "NONE", "coverage": "known-clean"},
  {"host": "mathea.as", "verdict": "NONE", "coverage": "known-clean"}
]
```

**Costs:** one hop from the signal node back to the hosts that carry it, bounded by the `LIMIT`; read the verdict columns next to the signal and notice they disagree.

> **Tip:** wildcard DNS is a configuration fact, not a verdict — plenty of legitimate operators use it, which is why almost every row reads clean. That is the point: the signal is an *infrastructure* property that no feed will ever list. Combine it with a registrant or nameserver pivot before you draw any conclusion from it.

**From here, →** [Shared-nameserver siblings](#shared-nameserver-siblings)

### How many siblings under the same apex share this IP?

Co-tenancy on its own means very little — thousands of unrelated sites share a CDN address. What says something is how many siblings *from the same apex* answer from the same address, because that is a fact about one operator's deployment rather than a shared platform.

```cypher expect=rows>0 seed=www.github.com verified=2026-09-02
// How many sibling hosts under the same apex share this IP?
MATCH (h:HOSTNAME {name: "www.github.com"})-[:CHILD_OF]->(apex)
MATCH (ip:IPV4 {name: "140.82.121.4"})<-[:RESOLVES_TO]-(sibling)-[:CHILD_OF]->(apex)
WHERE sibling <> h
RETURN apex.name AS apex, count(sibling) AS sibling_fanout
LIMIT 5
```
**Returns:** `apex, sibling_fanout`

**Sample output** (captured 2026-09-02):
```json
[{"apex": "github.com", "sibling_fanout": 3}]
```

**Costs:** two indexed anchors joined on the apex, with `WHERE sibling <> h` keeping the host from counting itself; a raw co-tenant count on a busy IP can run to six figures and mean nothing, where this number stays small and specific.

> **Tip:** this is the denominator that co-hosting scores are missing. Swap `IPV4` for `IPV6` to score the v6 side of the same estate, and pair it with `CALL whisper.identify(["<host>"])` — its `host_class` tells you whether the address is dedicated or a multi-tenant platform before you read anything into the fan-out.

**From here, →** [Co-tenancy: every domain on the same IP](#co-tenancy-every-domain-on-the-same-ip) for the full tenant list once the fan-out says the address is worth it.

## History & change tracking

### Watch how a domain's registration moved over time

**What the graph does:** current WHOIS is a single snapshot; the *changes* are the actual signal. [`whisper.history.whois`](/docs/whisper-graph/procedures/history) returns the timestamped WHOIS snapshots, one row per snapshot, so you can see exactly when the registration shifted. It needs an API key, so [sign in](https://console.whisper.security/sign-in?redirect_url=https%3A%2F%2Fwww.whisper.security%2Fdocs%2Frecipes%2Fthreat-intel) to run it — there is no card to enter.

```cypher expect=rows>0 seed=paypal-account-verify.com verified=2026-09-02
CALL whisper.history.whois("paypal-account-verify.com")
YIELD indicator, createDate, updateDate, registrar, registrant, nameServers
RETURN indicator, createDate, updateDate, registrar, registrant, nameServers
LIMIT 10
```

**Sample output** (captured 2026-09-02):
```json
[
  {"indicator": "paypal-account-verify.com", "createDate": "2019-08-01", "updateDate": "2019-08-01", "registrar": "Vitalwerks Internet Solutions, LLC / No-IP.com", "registrant": "", "nameServers": "static-1.no-ip.com|static-2.no-ip.com|static-3.no-ip.com"},
  {"indicator": "paypal-account-verify.com", "createDate": "2021-01-30", "updateDate": "2021-01-30", "registrar": "Google LLC", "registrant": "Contact Privacy Inc. Customer 1249278109", "nameServers": "ns-cloud-d1.googledomains.com|ns-cloud-d2.googledomains.com|ns-cloud-d3.googledomains.com|ns-cloud-d4.googledomains.com"}
]
```

> **Tip:** read the sample as a timeline — a fresh `createDate` under a new registrar with a privacy-proxy registrant is the domain being re-registered by someone else, which is a classic ownership-handoff marker. Always call the single-shape variant from a script: `whisper.history.whois(domain)` emits the same WHOIS columns every time, and `whisper.history.bgp(ip|asn|prefix)` the same routing columns, so a fixed `YIELD` never breaks. A subdomain folds up to its registrable parent (`registrableDomain` names the parent it resolved to), and a full URL folds to its host first. See [`whisper.history()`](/docs/whisper-graph/procedures/history) for the full procedure reference.

### De-cloak the real origin behind a CDN

**What the graph does:** [`whisper.origins`](/docs/whisper-graph/procedures/origins) reconstructs candidate origin IPs from MX/SPF, sibling, and crawl signals — highest confidence first.

```cypher expect=static seed=nytimes.com verified=2026-09-02 reason="whisper.origins weighs a wide candidate set and its result shifts as evidence accumulates, so this block shows a captured result rather than a live run"
CALL whisper.origins("nytimes.com")
YIELD ip, confidence, methods
RETURN ip, confidence, methods
ORDER BY confidence DESC
LIMIT 5
```

> **Tip:** `confidence` is a `0.0`–`1.0` scale, and `methods` names the signal each candidate came from (`sibling`, `links_to`, `mx`, `spf`) — read the two together rather than the number alone, since a `sibling` hit and a `links_to` hit are very different kinds of evidence, and a candidate found by more than one method outranks any single-method one. Lone mail-derived candidates are deliberately down-weighted, because third-party mail providers serve thousands of unrelated domains. By default the output is origin-grade only; pass `{include_related: true}` to also see CDN and shared-provider context, labelled by `kind` and `category`. A de-cloaked origin is a fresh seed: run each `ip` back through the hosting walk above for its network, and through co-tenancy and `explain()` for its neighbours — those often aren't behind the CDN and expose the rest of the campaign. Guided version: [Find the real infrastructure behind the CDN](/products/intelligence/use-cases/infrastructure-supply-chain/infrastructure-mapping).

## Batch & evidence collection

### Triage a list of indicators in one request

**What the graph does:** `UNWIND` a list and let the graph fan it out in a single round-trip. `OPTIONAL MATCH` keeps one row per input, so the names the graph has never seen come back too, instead of silently vanishing.

```cypher expect=rows>0 seed=paypal-account-verify.com verified=2026-09-02
// Batch verdict + registrar for a list of suspect domains, misses included
UNWIND ["paypal-account-verify.com", "secure-paypai.com", "paypal.com"] AS name
OPTIONAL MATCH (h:HOSTNAME {name: name})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
RETURN name, h IS NOT NULL AS known, h.verdictLevel AS level, h.verdictScore AS score,
       collect(DISTINCT r.name) AS registrars
LIMIT 100
```

> **Tip:** `known: false` means the graph has no node for that name — unknown to the graph, which is different from "assessed clean", so surface the gap to your analyst rather than implying a verdict. Names are stored lowercase, so fold the case in your own code before you build the list; an upper-case letter in an anchor matches nothing. For a verdict with coverage, or owner, country and network per indicator, hand the list to `whisper.assess` or `whisper.enrich` instead — see [Working in batches](/docs/recipes/cross-cutting#working-in-batches).

### Inbound web links: who points at the target

**What the graph does:** point `LINKS_TO` at a lookalike and you get what references it — the parked-domain networks, redirectors, and directory spam that push traffic at it. Point it at the legitimate brand instead and you get the reverse trick: phishing pages that link *to* the real site to look credible.

`LINKS_TO` (`HOSTNAME → HOSTNAME`) is a pilot sample of the web hyperlink graph in the same surface, so treat it as a lead generator rather than a census.

```cypher expect=rows>0,no-null-columns seed=payapl.com verified=2026-09-02
// Sites that link to a target host
MATCH (source:HOSTNAME)-[:LINKS_TO]->(h:HOSTNAME {name: "payapl.com"})
RETURN source.name AS linking_site
LIMIT 25
```

> **Tip:** flip the arrow — `(h)-[:LINKS_TO]->(target)` — to see what the suspect domain references outbound, which can expose shared kit, redirectors, or affiliate tracking tied to the campaign. The same edge type also carries phishing-kit membership from a `URL` node to the hosts serving that path; those recipes are on [Lookalike Hunting](/docs/recipes/brand-protection#phishing-kits).

## Where to go next

- **[Indicator Triage (SOC)](/docs/recipes/soc)** — the verdict-first triage workflow that feeds these pivots.
- **[Actor & ATT&CK layer](#actor-att-ck-layer)** — alias resolution, technique and tactic rollups, and the two sparse edges that reach live infrastructure.
- **[Cross-Layer Patterns](/docs/recipes/cross-cutting)** — the reusable multi-layer query patterns behind every recipe here, including the batch shapes.
- **[Procedures](/docs/whisper-graph/procedures)** — full signatures for `explain`, `whisper.variants`, `whisper.history.whois`, and `whisper.origins`.
- **[Graph Schema](/docs/whisper-graph/schema)** — every label, edge, and property, with direction notes.
- **[Threat Feeds & Categories](/docs/whisper-graph/threat-feeds)** — the 134 feeds and 33 categories behind the verdicts.
- **[AI & Agents](/docs/ai)** — point an MCP client at the graph and let your agent run these pivots itself, mid-investigation.

For inline enrichment in SPL, see [Splunk Use Cases for Infrastructure Intel](/docs/workflows) and [Enterprise Security Integration](/docs/integrations/splunk/es-integration).
