# Indicator Triage (SOC)

> SOC triage Cypher for WhisperGraph: reconciled verdicts, explain() evidence, feed categories, network attribution, co-hosting, TLS fingerprints, IOCs.

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

---
You've got an alert and a clock. These recipes take you from a raw indicator — an IP, a domain — to a reconciled verdict, the co-hosted blast radius, network attribution, and a copy-paste evidence chain, without leaving your terminal and without a black box. Every score returns the feeds and timestamps behind it.

[Sign in for an API key](https://console.whisper.security/sign-up?redirect_url=https%3A%2F%2Fwww.whisper.security%2Fdocs%2Fuse-cases%2Fthreat-investigation%2Findicator-triage) and you can run every recipe on this page as written. Anchor on a `{name: "..."}` and you'll get answers in milliseconds even across billions of edges. New here? Start with [Getting Started](/docs/whisper-graph/getting-started), and keep the [Graph Schema](/docs/whisper-graph/schema) and [Procedures](/docs/whisper-graph/procedures) open.

> **Run it live:** [Indicator Investigation](/products/intelligence/use-cases/threat-investigation/indicator) — the guided workflow that runs these pivots in the browser, opening with a live result you can rerun on your own indicator.

## First 30 seconds: is this thing bad?

### Reconciled verdict — one blocking-aware answer

Your SIEM flagged an IP. Before you touch the firewall you need a single answer: block or not, and why. Flat feeds disagree with each other — one list says C2, another never heard of it. The graph reconciles every feed that touched the indicator into one verdict you can act on, with the flags that explain it.

```cypher expect=rows>0,no-null-columns seed=185.220.101.1 verified=2026-09-02
// Triage on the reconciled verdict — prefer verdictScore over raw threatScore
MATCH (ip:IPV4 {name: "185.220.101.1"})
RETURN ip.verdictScore    AS score,
       ip.verdictLevel    AS level,
       ip.verdictBlocking AS block,
       ip.verdictCoverage AS coverage,
       ip.isC2, ip.isMalware, ip.isTor, ip.isAnonymizer
```

**Sample output** (captured 2026-09-02 — scores are live reads and move with the feeds):
```json
[{
  "score": 16.84, "level": "LOW", "block": false, "coverage": "known-clean",
  "ip.isC2": false, "ip.isMalware": false,
  "ip.isTor": true, "ip.isAnonymizer": true
}]
```

> **Tip**: Read `verdictCoverage` first, `verdictLevel` second, and the raw `threatScore` last. `malicious-evidenced` means the graph has positive evidence; `known-clean` means positive evidence the other way; anything else means the evidence is thin or absent, and a low score there is an absence of information, not a clean bill of health. `verdictScore` / `verdictLevel` / `verdictBlocking` are the reconciled triage signals — prefer them over raw `threatScore`. There is also `verdictAdvisory`, but it carries a note only in specific cases (`1.1.1.1` returns `allowlist-vouched`) and reads null on most indicators, so read it if you select it and never gate on it. The boolean `is*` flags (`isC2`, `isMalware`, `isPhishing`, `isTor`, `isAnonymizer`, `isThreat`) tell you *what kind* of bad in one row. Public resolvers on the curated allowlist (`1.1.1.1`, `8.8.8.8`) read `INFO` and `false` on the verdict surfaces by design; the raw `threatScore` is never clamped, so `WHERE ip.threatScore > 5` still matches them. If `verdictLevel` is `NONE`, no feed flagged it — but no-data is not the same as benign (see the coverage-qualified verdict below).

### explain() — the verdict's evidence chain

`verdictLevel` is the headline; [`explain()`](/docs/whisper-graph/procedures/explain) is the paragraph you paste into the ticket. It returns the exact feeds, their weights, the scoring arithmetic, and first/last-seen — an inspectable chain, not a number from nowhere.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
// Scored verdict + every contributing feed, with weights and timestamps
CALL explain("185.220.101.1")
YIELD indicator, score, level, explanation, factors, sources
RETURN indicator, score, level, explanation, factors, sources
LIMIT 1
```

**Sample output** (captured 2026-08-09 — the arithmetic is stable, the numbers move with the feeds):
```json
[{
  "indicator": "185.220.101.1",
  "score": 21.44,
  "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:48Z", "lastSeen": "2026-08-08T10:06:47Z"},
    {"feedId": "firehol-abusers-1d", "weight": 1.5, "firstSeen": "2026-08-03T16:21:20Z", "lastSeen": "2026-08-07T07:51:07Z"},
    {"feedId": "greensnow", "weight": 1.0, "firstSeen": "2026-08-03T16:22:08Z", "lastSeen": "2026-08-06T07:38:10Z"},
    {"feedId": "firehol-level2", "weight": 1.3, "firstSeen": "2026-08-04T17:38:44Z", "lastSeen": "2026-08-04T17:38:44Z"},
    {"feedId": "stopforumspam-listed-ip-7d", "weight": 0.5, "firstSeen": "2026-08-03T16:22:10Z", "lastSeen": "2026-08-06T07:38:35Z"},
    {"feedId": "stamparm-ipsum", "weight": 1.2, "firstSeen": "2026-08-03T16:22:08Z", "lastSeen": "2026-08-08T23:45:58Z"}
  ]
}]
```

> **Tip**: `explain()` auto-detects the indicator type — it works on IPs, domains, ASNs (`AS13335`), and CIDR ranges (`185.220.101.0/24`). Scores are live reads: the value reflects whatever feeds are loaded right now. To keep only the feeds that moved the score, `UNWIND sources AS s` and filter `WHERE s.weight >= 1.0` — everything below the floor is corroboration, everything above it is the case. `explain` also exists as an MCP tool if your SOAR is agent-driven — see [AI & Agents](/docs/ai).

### Coverage-qualified verdict — "no-data ≠ benign"

An empty verdict is the trap. A host on a big cloud isn't malicious because the cloud also hosts malware, and a host *no feed has ever seen* is unknown, not clean. The graph answers identity and danger as separate questions, and gates the danger answer on coverage.

```cypher expect=rows>0 verified=2026-09-02
// Whose infrastructure is this, separately from whether it's dangerous
CALL whisper.identify(["github.com"])
YIELD host, canonical_name, host_class, roles, confidence
RETURN host, canonical_name, host_class, roles, confidence
LIMIT 5
```

**Sample output** (measured 2026-09-02):
```json
[{
  "host": "github.com", "canonical_name": "Github",
  "host_class": "multi_tenant_user_content",
  "roles": ["DNS_OPERATOR", "MAIL_RECEIVER", "ORIGIN_AS"], "confidence": 0.85
}]
```

```cypher expect=rows>0 verified=2026-09-02
// Is it dangerous — qualified by coverage (gate on this, don't trust an empty band)
CALL whisper.assess(["github.com"])
YIELD host, label, band, coverage, signals
RETURN host, label, band, coverage, signals
LIMIT 5
```

**Sample output** (measured 2026-09-02):
```json
[{
  "host": "github.com", "label": "benign-allowlisted", "band": "NONE", "coverage": "known-clean",
  "signals": [
    {"source": "reconciler", "kind": "url-scoped-listing", "confidence": 1.0},
    {"source": "url-path-listing", "kind": "path-listings", "count": 3, "listings": [
      {"path": "/up-6626", "band": "NONE", "categories": []},
      {"path": "/pistacchietto/Win-Python-Backdoor/raw/master/win.bat", "band": "HIGH", "categories": ["malware"]},
      {"path": "/4realgg/Helper-Update1.0/releases/download/update1/mw--58389c35-c76b-46ac-b33e-7efe83b65fda.zip", "band": "CRITICAL", "categories": ["c2"]}
    ]}
  ]
}]
```

Read that row carefully, because it is the whole lesson: the apex is `known-clean` at `NONE`, one path under it is `HIGH` for malware and another is `CRITICAL` for C2. A host-level clean verdict does not clear a path or a tenant on a multi-tenant host.

> **Tip**: Read `coverage` before `band`. `known-clean` is the only value that licenses closing on clean. `malicious-evidenced` means positive evidence exists even at a low band. `ambiguous` means escalate to a human. `no-data` means we have never seen this host — and it is the modal answer for a bare IPv4 address, so follow the no-data playbook in [Coverage](/docs/whisper-graph/procedures/coverage) rather than escalating everything. `host_class` (`multi_tenant_user_content`, `dedicated`, cloud, CDN) tells you whether co-tenancy is even meaningful before you pivot on it. `whisper.assess` also takes a single host string when you only have one, and folds a full URL down to its host.

> Every Whisper verdict answers two independent questions. `band` tells you **how bad**. `coverage`
> tells you **what we actually looked at**. Read both. They are a grid, not a ladder.

**Only `known-clean` licenses the word "clean". Every other value is not-clean — and `no-data` and
`deadline-hit` mean *unknown*, which is a different thing again.**

`whisper.assess`, `whisper.assessUrl`, `whisper.explain` and `whisper.walk` all return `coverage`, and it is not about the same thing in each.

| `coverage` | What it means | What to do |
|---|---|---|
| `known-clean` | We hold data at this granularity and nothing malicious is in it. | Treat as clean. **This is the only value that licenses closing a ticket on "clean."** |
| `malicious-evidenced` | **Some** positive evidence of malice exists. It may be a single feed at weight 0.5. It does **not** mean the band is high. | Read `evidence[]` for `feed-source-count`, then run `explain()` for the per-feed provenance, weights and timestamps. A count of 1 on a low-weight aggregate list is a lead, not a finding. |
| `ambiguous` | The evidence points both ways — for example an anonymising-egress signal alongside generic abuse listings. | **Escalate to a human. Do not automate a decision on this value.** |
| `no-data` | We have never observed this host. | Unknown. Never benign. Ask a different question — the container, the operator, the age — and escalate with "we have no observation of this host", never with "it came back clean." |

Every one of these arrives as a **populated row**. `no-data` is a row that says `no-data`; it is
never an empty result set. If a query returns zero rows, the first hypothesis is that the query is
wrong, not that the host is clean.

**Which procedure carries `coverage`:**

| Procedure | Returns `coverage`? | What its `coverage` is about |
|---|---|---|
| `whisper.assess` | **Yes** | Threat coverage. The four values above. |
| `whisper.assessUrl` | Yes | A path axis, not a host axis — read [the contract](/docs/whisper-graph/procedures/coverage#procedure-contract) before gating on it. |
| `whisper.walk` | Yes, but **not a verdict** | Atlas and vendor adjacency — whether the host is reachable in the graph's structure. Emits presence-axis values only. |
| `whisper.explain` | **Yes** | Threat coverage; for a host or an address, the same value `whisper.assess` returns. A `NONE` level from `explain()` is a score, not a clean verdict: read `coverage` beside it. |

`structural-only` is a `whisper.walk` value describing atlas adjacency. **It is not a
`whisper.assess` value**, and a branch keyed on it in an `assess` result is unreachable — see
[the full contract](/docs/whisper-graph/procedures/coverage#not-assess-values).

## Which feeds, and what kind of bad

### Which threat categories put this IP on a feed?

Feed names mean little in an incident summary. The categories behind them — Tor, malware, general blacklist — are what a manager reads and what decides which team owns the ticket. Each feed belongs to a category, so the translation is one more hop.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
// IP -> feeds -> categories
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:LISTED_IN]->(f:FEED_SOURCE)
WITH f LIMIT 10
MATCH (f)-[:BELONGS_TO]->(c:CATEGORY)
RETURN f.displayName AS feed, c.displayName AS category
LIMIT 20
```
**Returns:** `feed, category`

**Sample output** (captured 2026-09-02):
```json
[
  {"feed": "GreenSnow Blacklist", "category": "General Blacklists"},
  {"feed": "IPsum", "category": "General Blacklists"},
  {"feed": "FireHOL Level 2", "category": "General Blacklists"}
]
```

**Costs:** two bounded hops from an indexed anchor; `LISTED_IN` and the feed-to-category step are computed at query time, so the `WITH f LIMIT 10` keeps the second hop tight; an indicator on no feed returns no rows, which is a listing fact, not a verdict.

> **Tip**: `FEED_SOURCE.name` is the slug (`greensnow`), `displayName` is the human name, and `CATEGORY.id` (`tor`, `c2`, `phishing`) is what you filter on. `MATCH (c:CATEGORY) RETURN c.id, c.displayName LIMIT 25` lists the vocabulary; the full catalogue with weights is on [Threat Feeds & Categories](/docs/whisper-graph/threat-feeds).

**From here, →** [Which feeds still list it once you exclude a category you already know about?](#which-feeds-still-list-it-once-you-exclude-a-category-you-already-know-about)

### Which feeds still list it once you exclude a category you already know about?

A Tor exit is on Tor feeds by definition; you knew that when the alert fired. The ticket changes only if something *else* flags it — a brute-force source, a scanner list. Exclude the category you have already accounted for and read what remains.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
// Feeds listing this indicator, minus the ones you already know about
MATCH (n:IPV4 {name: "185.220.101.1"})-[:LISTED_IN]->(f:FEED_SOURCE)
WHERE NOT (f)-[:BELONGS_TO]->(:CATEGORY {id: "tor"})
RETURN n.name AS indicator, collect(DISTINCT f.displayName) AS feeds_excluding_tor
LIMIT 5
```
**Returns:** `indicator, feeds_excluding_tor`

**Sample output** (captured 2026-09-02):
```json
[{"indicator": "185.220.101.1", "feeds_excluding_tor": ["GreenSnow Blacklist", "IPsum", "FireHOL Level 2", "StopForumSpam Listed IPs (7 day)", "duggytuxy-datashield-critical"]}]
```

**Costs:** one anchored hop plus a negated pattern filter; the category never joins into the result, so the row stays one line; an indicator whose only listings are in the excluded category returns no rows, which is the answer you wanted.

> **Tip**: The negated pattern in the `WHERE` is the whole recipe — it filters on a relationship the feed has without pulling the category into your output. Swap the id to suppress whatever your environment already treats as expected: `anonymizer`, `vpns`, `proxies`, `popularity`, `ad-tracking`. This is how you stop an anonymising-infrastructure listing from drowning out the one feed that actually says something new.

**From here, →** [explain() — the verdict's evidence chain](#explain-the-verdict-s-evidence-chain) for the weight each remaining feed carried.

## Network attribution & GeoIP

### Trace an IP to its network owner

![Diagram: an IPV4 address is ANNOUNCED_BY an ANNOUNCED_PREFIX, which an ASN ROUTES. The ASN HAS_NAME an ASN_NAME and is AS_PRESENT_AT a FACILITY, a datacenter. The address also BELONGS_TO its PREFIX, the allocation block. Documentation-range values.](/media/hy1vnxRKq9X_fjIa_whisper-ip-to-facility.svg "Which network announces this address, and where is that network present?")

The alert names an IP. Before escalating you need to know who owns it and what network it sits in. One hop chain gives you both: IP → announced prefix → ASN → network name.

```cypher expect=rows>0 seed=104.16.132.229 verified=2026-09-02
// Full BGP chain: IP -> announced prefix -> ASN -> network name
MATCH (ip:IPV4 {name: "104.16.132.229"})
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:ROUTES]->(a:ASN)
      -[:HAS_NAME]->(n:ASN_NAME)
RETURN ip.name AS ip, ap.name AS prefix, a.name AS asn, n.name AS network
LIMIT 5
```

**Sample output**:
```json
[{"ip": "104.16.132.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."}]
```

> **Tip**: `ANNOUNCED_BY` reflects current BGP routing, so you always get the live announcement. `ROUTES` is undirected — it matches whichever arrow you write. For IPs with no live announcement, `BELONGS_TO` gives the registered allocation block instead. Anchor network analytics (`asRank`, `coneAsns`, `routeLeakCount`) on the `:ASN` node, never on the `ASN_NAME` node, which carries only its name.

### Who really operates this netblock?

The WHOIS owner of a prefix is often a registry, not the operator actually running the address space. `DELEGATED_TO` resolves an IP or prefix to the cloud/SaaS vendor behind it — useful when the WHOIS org is a shell or a reseller.

```cypher expect=rows>0 seed=104.16.132.229 verified=2026-09-02
// Vendor operating the address space (distinct from the WHOIS owner)
MATCH (ip:IPV4 {name: "104.16.132.229"})-[:DELEGATED_TO]->(v:VENDOR)
RETURN ip.name, v.name AS vendor
LIMIT 1
```

**Sample output**:
```json
[{"ip.name": "104.16.132.229", "vendor": "cloudflare"}]
```

> **Tip**: If the prefix itself carries the delegation, anchor on the prefix: `MATCH (ip:IPV4 {name:"..."})-[:BELONGS_TO]->(p:PREFIX)-[:DELEGATED_TO]->(v:VENDOR)`. Vendor identity tells you *who to send the abuse report to*, which the WHOIS contact often won't.

### Look up GeoIP location

You need the physical location for a geo-restriction check or an incident report.

```cypher expect=rows>0 seed=109.111.100.154 verified=2026-09-02
// GeoIP city and country for an IP
MATCH (ip:IPV4 {name: "109.111.100.154"})
      -[:LOCATED_IN]->(city:CITY)
      -[:HAS_COUNTRY]->(co:COUNTRY)
RETURN DISTINCT ip.name, city.name AS city, co.name AS country
LIMIT 1
```

**Sample output**:
```json
[{"ip.name": "109.111.100.154", "city": "Andorra la Vella, AD", "country": "AD"}]
```

> **Tip**: Anycast IPs often return no city-level GeoIP because they're served from many locations at once. For those, fall back to the BGP chain below.

### Country via BGP when GeoIP is empty

When `LOCATED_IN` returns nothing, get the country from the announcing network's allocation.

```cypher expect=rows>0 seed=8.8.8.8 verified=2026-09-02
// Country via BGP prefix allocation
MATCH (ip:IPV4 {name: "8.8.8.8"})
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:HAS_COUNTRY]->(co:COUNTRY)
RETURN ip.name, ap.name AS prefix, co.name AS country
LIMIT 1
```

**Sample output**:
```json
[{"ip.name": "8.8.8.8", "prefix": "8.8.8.0/24", "country": "US"}]
```

> **Tip**: This works even when GeoIP is empty. The country reflects where the announcing network is registered, not the physical server.

## Co-hosted infrastructure & blast radius

### Reverse DNS: what else is hosted here?

You have an IP from an alert and want to know what else lives on it. `RESOLVES_TO` is `HOSTNAME → IP`, so reverse it.

```cypher expect=rows>0 seed=104.16.132.229 verified=2026-09-02
// All domains currently resolving to this IP
MATCH (ip:IPV4 {name: "104.16.132.229"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN h.name LIMIT 20
```

**Sample output**:
```json
[
  {"h.name": "menuchin.app"},
  {"h.name": "www.menuchin.app"},
  {"h.name": "qapy.com.ar"},
  {"h.name": "c-cloudflare-com.4i.am"}
]
```

> **Tip**: Shared hosting is normal for CDN IPs — one Cloudflare IP can front thousands of domains. Count first (next recipe) before you treat co-tenancy as attribution.

### Count co-hosted domains before pivoting

```cypher expect=rows>0 seed=104.16.132.229 verified=2026-09-02
// How many domains share this IP?
MATCH (ip:IPV4 {name: "104.16.132.229"})<-[:RESOLVES_TO]-(h:HOSTNAME)
RETURN count(h) AS cohosted
LIMIT 1
```

**Sample output** (captured 2026-09-02):
```json
[{"cohosted": 1516}]
```

> **Tip**: A count over a few hundred usually means shared CDN or hosting infrastructure — co-tenancy there is noise. A count under 20 is the interesting case: those domains are likely run by the same operator, worth pivoting through `explain()` one by one. Use plain `count()` here, not `count(DISTINCT ...)`: for a "how crowded is this?" question the order of magnitude is what you need, and it is the lighter call.

### Neighborhood toxicity — threat density per prefix

You want to know how many threat-listed IPs share a network prefix with the one you're investigating — a fast read on whether you've stepped into a bad neighborhood. The precomputed `threatNeighborCount` does it in a single hop, even on hyperscaler blocks.

```cypher expect=rows>0 verified=2026-09-02
// Toxic neighbor count for an IP's registered prefix
MATCH (ip:IPV4 {name: "45.148.10.35"})-[:BELONGS_TO]->(p:PREFIX)
RETURN p.name AS prefix, p.threatNeighborCount AS toxic_neighbors
LIMIT 1
```

**Sample output** (captured 2026-09-02):
```json
[{"prefix": "45.148.10.0/24", "toxic_neighbors": 146}]
```

The same counter lives on the live announcement — anchor through `ANNOUNCED_BY` when you want the routed block instead of the registered allocation:

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
// Same read against the announced (routed) prefix
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
RETURN ap.name AS prefix, ap.threatNeighborCount AS toxic_neighbors
LIMIT 1
```

**Sample output** (captured 2026-09-02):
```json
[{"prefix": "185.220.101.0/24", "toxic_neighbors": 170}]
```

> **Tip**: Don't write `WHERE o.isThreat = true RETURN count(o)` — that enumerates every IP in the prefix (up to ~1M on hyperscaler blocks) and times out. The counter is precomputed and refreshed with the feed cycles. A registered allocation can be far wider than the routed block, so if the registered-prefix read looks flat, check the announced prefix too. For a per-address ratio you can compare across whole networks, `CALL whisper.asnThreatDensity("AS14061")` returns listed addresses over announced space in one call.

## Pivot the campaign: egress, fingerprints, certificates

### Is this a Tor exit, and which relay?

An `isTor: true` flag means anonymizing egress rather than the operator's own server — different ticket, different response. The Tor-relay identity survives IP rotation, so you can track the operator across address changes.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
// Tor-exit identity behind an IP (survives IP rotation)
MATCH (ip:IPV4 {name: "185.220.101.1"})-[:OPERATES_EXIT_NODE]->(r:TOR_RELAY)
RETURN ip.name, r.name AS relay_fingerprint
LIMIT 5
```

**Sample output**:
```json
[
  {"ip.name": "185.220.101.1", "relay_fingerprint": "6c64100d8f7050e76f420ce404031eabc7101124"},
  {"ip.name": "185.220.101.1", "relay_fingerprint": "8f744605199e75c26f74e818bde50d9a7325ec94"}
]
```

> **Tip**: Pair this with the `isTor` / `isAnonymizer` flags from the verdict recipe. Anonymizing egress means you can't attribute the human behind it from the IP alone — a known posture, not a mystery. For the fuller relay record, `CALL whisper.lookupTorRelay("185.220.101.1")` returns the fingerprint, exit addresses and source in one call.

### Track C2 across changing domains via TLS fingerprint

C2 operators rotate domains and IPs but reuse the same TLS stack. The JA3/JARM fingerprint pins the *server software*, so you can find other IPs presenting the same fingerprint as a known-bad host — infrastructure the operator forgot to change.

```cypher expect=rows>0 seed=144.217.207.19 verified=2026-09-02
// IPs sharing a TLS fingerprint with a known indicator
MATCH (ip:IPV4 {name: "144.217.207.19"})-[:EMITS_TLS_FINGERPRINT]->(f:TLS_FINGERPRINT)
MATCH (f)<-[:EMITS_TLS_FINGERPRINT]-(peer:IPV4)
WHERE peer.name <> ip.name
RETURN f.name AS fingerprint, collect(DISTINCT peer.name)[..25] AS peers_same_tls
LIMIT 1
```

Empty result: coverage on this plane is partial, so expect no match on almost any indicator. **A zero-row result here means Whisper holds no observation — not that the host shares no infrastructure.**

> **Tip**: Bound the fan-out — a common JARM can be shared by thousands of benign hosts, so slice the collected list (`[..25]`) and run each candidate through `explain()` before calling it related. A *rare* fingerprint shared by a handful of IPs is the strong signal. To find a live anchor, run `MATCH (ip:IPV4)-[:EMITS_TLS_FINGERPRINT]->(f:TLS_FINGERPRINT) RETURN ip.name, f.name LIMIT 5` and pivot from one of those. Fingerprint names carry their scheme as a prefix (`jarm:`, `ja3:`), so anchor on the full string when you pivot to a specific one.

### What is this TLS fingerprint hash?

Your sensor emitted a JA3 or JARM hash. Before you cluster on it, find out what it is: a known scanner, a common VPN client, or something with no public identity. That decides whether a fingerprint match is evidence of anything at all.

```cypher expect=rows>0 seed=ja3:a35c1457421bcfaf5edaccb910bfea1d verified=2026-09-02
// What is this JA3 fingerprint, and is it benign?
CALL whisper.lookupTlsFingerprint("ja3:a35c1457421bcfaf5edaccb910bfea1d")
YIELD indicator, found, kind, category, label, family, vendor, sourceCount
RETURN indicator, found, kind, category, label, family, vendor, sourceCount
LIMIT 5
```
**Returns:** `indicator, found, kind, category, label, family, vendor, sourceCount`

**Sample output** (captured 2026-09-02):
```json
[{"indicator": "ja3:a35c1457421bcfaf5edaccb910bfea1d", "found": true, "kind": "ja3", "category": "BENIGN", "label": "OpenConnect version v7.01", "family": null, "vendor": null, "sourceCount": 1}]
```

**Costs:** one procedure call, no traversal; the argument is a hash, bare or `ja3:`/`jarm:`-prefixed; a hostname is accepted and comes back `found: false` with every column null, which reads exactly like an unknown fingerprint, so check `found` before you read anything else.

> **Tip**: A `category` of `BENIGN` with a named `label` is the useful answer — it says the handshake belongs to a common client build, so a match on it is not evidence. `family` and `vendor` are populated only when a public identity names them.

**From here, →** [Track C2 across changing domains via TLS fingerprint](#track-c2-across-changing-domains-via-tls-fingerprint) to find the servers presenting a fingerprint that did turn out to be distinctive.

### Discover subdomains from Certificate Transparency

A lookalike registers `koinbase.com` and gets a cert — which lands in CT logs the moment it's issued, often before DNS resolves or a feed notices. CT surfaces SANs and subdomains you won't find by resolving the apex.

```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"
// Subdomains / SANs seen in Certificate Transparency for a domain
MATCH (h:HOSTNAME {name: "koinbase.com"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.name AS ct_observation
LIMIT 25
```

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

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.

A `*.` in the result is a wildcard SAN: the operator can stand up any subdomain under it without a fresh certificate, so treat the whole namespace as in play.

> **Tip**: CT is your earliest-warning surface for lookalike infrastructure. Combine it with `whisper.variants()` (next recipe) to catch typosquats that have already pulled a certificate.

### Catch the lookalike domain behind the lure

A user reports a phishing email from `paypa1.com`. You want every registered lookalike of your brand and a verdict on each — without brainstorming permutations by hand.

```cypher expect=rows>0 seed=paypal.com verified=2026-09-02
// Registered typosquats / lookalikes of a brand
CALL whisper.variants("paypal.com")
YIELD variant, method, exists, confidenceLabel
WHERE exists
RETURN variant, method, confidenceLabel
LIMIT 15
```

> **Tip**: `exists: true` means *registered*, not malicious — pivot each hit straight through `explain(variant)` for a verdict. Generation covers character omission, repetition, transposition, keyboard-adjacent swaps, homoglyphs, bitsquatting, TLD swap, and more — see [whisper.variants()](/docs/whisper-graph/procedures/variants). Also available as the `domain_variants` MCP tool.

## WHOIS, DNS & evidence collection

### Quick WHOIS check

You need registration details for a suspicious domain — registrar, contact emails, phones.

```cypher expect=rows>0 seed=cloudflare.com verified=2026-09-02
// WHOIS registration profile for a domain
MATCH (h:HOSTNAME {name: "cloudflare.com"})
OPTIONAL MATCH (h)-[:HAS_REGISTRAR]->(r:REGISTRAR)
OPTIONAL MATCH (h)-[:HAS_EMAIL]->(e:EMAIL)
OPTIONAL MATCH (h)-[:HAS_PHONE]->(p:PHONE)
RETURN h.name,
       collect(DISTINCT r.name) AS registrars,
       collect(DISTINCT e.name) AS emails,
       collect(DISTINCT p.name) AS phones
LIMIT 1
```

**Sample output**:
```json
[{
  "h.name": "cloudflare.com",
  "registrars": ["iana:1910"],
  "emails": ["domains@cloudflare.com", "noreply@data-protected.net"],
  "phones": ["+10000000000", "+16503198930"]
}]
```

> **Tip**: Use `OPTIONAL MATCH` for WHOIS fields — not every domain has every field. A plain `MATCH` would drop the whole row for a partially-registered domain. To pivot to siblings sharing a registrant email, reverse `HAS_EMAIL`: `(:EMAIL {name:"..."})<-[:HAS_EMAIL]-(:HOSTNAME)`.

### Has the registrar changed? (WHOIS history)

A sudden registrar transfer on an established domain is a takeover or resale signal. [`whisper.history.whois()`](/docs/whisper-graph/procedures/history) returns the timestamped WHOIS trail in one call, one row per historical snapshot.

```cypher expect=rows>0 seed=google.com verified=2026-09-02
// WHOIS history — registrar transfers, registrant changes
CALL whisper.history.whois("google.com")
YIELD createDate, updateDate, registrar, registrant, nameServers
RETURN createDate, updateDate, registrar, registrant, nameServers
LIMIT 3
```

**Sample output** (captured 2026-09-02):
```json
[
  {"createDate": "1997-09-05", "updateDate": "2024-08-02", "registrar": "MarkMonitor, Inc.", "registrant": "Google LLC", "nameServers": "ns1.google.com|ns2.google.com|ns3.google.com|ns4.google.com"},
  {"createDate": "1997-09-15", "updateDate": "2015-06-12", "registrar": "MarkMonitor, Inc.", "registrant": "Google Inc.", "nameServers": "ns1.google.com|ns2.google.com|ns3.google.com|ns4.google.com"}
]
```

> **Tip**: The history procedures need a key, so [sign in](https://console.whisper.security/sign-in?redirect_url=https%3A%2F%2Fwww.whisper.security%2Fdocs%2Frecipes%2Fsoc) to run them. Use the single-shape variants: `whisper.history.whois(domain)` always emits the same WHOIS columns, and `whisper.history.bgp(ip|asn|prefix)` always emits the same routing columns, so a fixed `YIELD` never breaks between calls. The general `whisper.history(indicator)` picks the shape from the indicator at runtime, which is fine by hand but not from a script. Keep a `LIMIT` on the routing form and expect a longer round trip for a large network.

### Who controls DNS?

The nameserver is often the clearest tell of who manages the infrastructure. `NAMESERVER_FOR` points server → domain, so traverse it backwards.

```cypher expect=rows>0 seed=google.com verified=2026-09-02
// Authoritative nameservers for a domain
MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(h:HOSTNAME {name: "google.com"})
RETURN ns.name LIMIT 10
```

**Sample output**:
```json
[
  {"ns.name": "ns1.google.com"},
  {"ns.name": "ns2.google.com"},
  {"ns.name": "ns3.google.com"},
  {"ns.name": "ns4.google.com"}
]
```

> **Tip**: Same direction rule for mail — a domain's MX is `(:HOSTNAME {name:"..."})<-[:MAIL_FOR]-(mx:HOSTNAME)`. Free or bulletproof-hosting nameservers on an otherwise-corporate domain are worth flagging.

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

The IP you see is the CDN edge. To geolocate, block, or attribute the actual server you need the origin behind it — [`whisper.origins()`](/docs/whisper-graph/procedures/origins) derives candidates from MX/SPF, sibling and crawl signals.

```cypher expect=static seed=cloudflare.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"
// Candidate real origin IPs behind a CDN/proxy
CALL whisper.origins("cloudflare.com")
YIELD ip, confidence, methods, asnName
RETURN ip, confidence, methods, asnName
ORDER BY confidence DESC
LIMIT 5
```

**Sample output** (captured 2026-09-02):
```json
[
  {"ip": "192.28.154.211", "confidence": 0.4499, "methods": ["sibling"], "asnName": "OMNITURE - Adobe Inc."},
  {"ip": "208.91.112.55", "confidence": 0.4499, "methods": ["sibling"], "asnName": "FORTINET - Fortinet Inc."},
  {"ip": "156.154.112.36", "confidence": 0.0948, "methods": ["mx"], "asnName": "VERCARA - Vercara, LLC"}
]
```

> **Tip**: `confidence` is a `0.0`–`1.0` scale and `methods[]` names how each candidate was found, so weigh the two together. The strongest signal is corroboration: an IP found by more than one method scores highest. A lone `mx` or `spf` hit is the weakest — third-party mail providers serve mail for thousands of unrelated domains, so those IPs are shared infrastructure, and the procedure down-weights them so they cannot bury the real origin. Start at `WHERE confidence >= 0.4` to keep sibling-grade and corroborated candidates; raise the floor to `0.5` when you only want corroborated web origins. A high-confidence origin on a different ASN than the CDN edge is your real block target.

### Full infrastructure trace for the report

Document the complete path from domain to network owner. A domain resolving to IPs on different ASNs can mean multi-CDN, load balancing, or — rarely — a hijack artifact; capture every row.

```cypher expect=rows>0 seed=cloudflare.com verified=2026-09-02
// Full chain: domain -> IP -> BGP prefix -> ASN -> network name
MATCH (h:HOSTNAME {name: "cloudflare.com"})
      -[:RESOLVES_TO]->(ip:IPV4)
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:ROUTES]->(a:ASN)
      -[:HAS_NAME]->(n:ASN_NAME)
RETURN h.name AS host, ip.name AS ip, ap.name AS prefix,
       a.name AS asn, n.name AS network
LIMIT 10
```

**Sample output**:
```json
[
  {"host": "cloudflare.com", "ip": "104.16.132.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."},
  {"host": "cloudflare.com", "ip": "104.16.133.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."}
]
```

### Batch IOC enrichment

You've got a list of indicators from an alert and want them all enriched in one round-trip. `UNWIND` turns the list into rows.

```cypher expect=rows>0 seed=185.220.101.1 verified=2026-09-02
// Enrich multiple IPs in one request, with reconciled verdict per IP
UNWIND ["185.220.101.1", "104.16.132.229", "8.8.8.8"] AS ip_addr
MATCH (ip:IPV4 {name: ip_addr})
      -[:ANNOUNCED_BY]->(ap:ANNOUNCED_PREFIX)
      -[:ROUTES]->(a:ASN)
RETURN ip_addr, ap.name AS prefix, a.name AS asn,
       ip.verdictLevel AS level, ip.verdictBlocking AS block
LIMIT 25
```

**Sample output** (captured 2026-09-02):
```json
[
  {"ip_addr": "185.220.101.1", "prefix": "185.220.101.0/24", "asn": "AS60729", "level": "LOW", "block": false},
  {"ip_addr": "104.16.132.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "level": "NONE", "block": false},
  {"ip_addr": "8.8.8.8", "prefix": "8.8.8.0/24", "asn": "AS15169", "level": "INFO", "block": false}
]
```

> **Tip**: `UNWIND` handles hundreds of indicators per query. To run the full scored verdict on each, chain `CALL explain(ip_addr)` after the `UNWIND` — but each `explain()` is a separate backend call, so keep that list modest. Reading `verdictLevel`/`verdictBlocking` straight off the node is the cheaper batch path. For a verdict with coverage, or owner, country and network per indicator, hand the whole list to `whisper.assess` or `whisper.enrich` instead — see [Working in batches](/docs/recipes/cross-cutting#working-in-batches).

### Hit it from the command line

Everything above is one HTTP POST. A quick single-hop read runs without a key; the deeper attribution chains need one.

```bash
curl -s https://graph.whisper.security/api/query \
  -H "Content-Type: application/json" \
  -d '{"query":"MATCH (ip:IPV4 {name:\"185.220.101.1\"}) RETURN ip.verdictLevel, ip.verdictBlocking, ip.isTor"}'
```

> **Tip**: Add your key header (`-H "X-API-Key: $WHISPER_KEY"`; `Authorization: Bearer` also works) to run the multi-hop attribution chains. Wire the same call into a SOAR playbook and every alert arrives pre-enriched. Full request and response shapes: [API Reference](/docs/cypher-api/reference).

**Key concepts:** [Indicator of compromise](/glossary/indicator-of-compromise) · [ASN reputation](/glossary/asn-reputation) · [Reconciled verdict](/glossary/reconciled-verdict) · [TLS fingerprint](/glossary/tls-fingerprint).

## Going deeper

- **More patterns** — [Cross-Layer Patterns](/docs/recipes/cross-cutting) has the copy-paste pivots that apply across every use case, including batch enrichment and bounded fan-out.
- **Every label, edge, and property** — the [Graph Schema](/docs/whisper-graph/schema), and full procedure signatures in [Procedures](/docs/whisper-graph/procedures).
- **Feeds behind the verdict** — [Threat Feeds & Categories](/docs/whisper-graph/threat-feeds) lists all 134 feeds and 32 categories and their weights.
- **Agent-driven triage** — point your SOAR or assistant at the MCP surface; see [AI & Agents](/docs/ai) and [MCP Setup](/docs/ai/mcp/setup).

## Splunk equivalents

Enriching events inline rather than running ad-hoc Cypher? The same workflows in SPL: [Splunk Use Cases for Infrastructure Intel](/docs/workflows). For `whisperlookup` and `whisperquery` see [Search Commands](/docs/integrations/splunk/using-it#search-commands).
