Skip to content
Recipes
Skip navigation
Recipes
View as Markdown

Indicator Triage (SOC)

From a raw IP or domain to a reconciled verdict, its feeds, network attribution, co-hosted blast radius, and a paste-ready evidence chain.

Published Last updated

On this page (32)

Indicator Triage (SOC) Documentation

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 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, and keep the Graph Schema and Procedures open.

Run it live: Indicator Investigation — 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 · runnablegraph.whisper.securitySign in to run
// 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 — coverage: malicious-evidenced. In coverage, with positive evidence of malice. means the graph has positive evidence; known-clean — coverage: known-clean. In coverage, no malicious evidence. 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() 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 · runnablegraph.whisper.securitySign in to run
// 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.

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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 — coverage: known-clean. In coverage, no malicious evidence. 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 — coverage: known-clean. In coverage, no malicious evidence. is the only value that licenses closing on clean. malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. means positive evidence exists even at a low band. ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. means escalate to a human. no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. 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 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 — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean". Every other value is not-clean — and no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. 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.

coverageWhat it meansWhat to do
known-clean — coverage: known-clean. In coverage, no malicious evidence.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 — coverage: malicious-evidenced. In coverage, with positive evidence of malice.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 — coverage: ambiguous. In coverage, and the evidence points both ways.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 — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at.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 — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. is a row that says no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at.; 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:

ProcedureReturns coverage?What its coverage is about
whisper.assessYesThreat coverage. The four values above.
whisper.assessUrlYesA path axis, not a host axis — read the contract before gating on it.
whisper.walkYes, but not a verdictAtlas and vendor adjacency — whether the host is reachable in the graph's structure. Emits presence-axis values only.
whisper.explainYesThreat 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-onlywalk — a whisper.walk value describing atlas adjacency. Not an assess coverage value. Do not gate on it. 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.

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 · runnablegraph.whisper.securitySign in to run
// 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.

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?

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 · runnablegraph.whisper.securitySign in to run
// 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 for the weight each remaining feed carried.

Network attribution & GeoIP

Trace an IP to its network owner

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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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 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
// 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 · runnablegraph.whisper.securitySign in to run
// 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(). 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 · runnablegraph.whisper.securitySign in to run
// 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() returns the timestamped WHOIS trail in one call, one row per historical snapshot.

cypher · runnablegraph.whisper.securitySign in to run
// 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 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 · runnablegraph.whisper.securitySign in to run
// 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() derives candidates from MX/SPF, sibling and crawl signals.

cypher
// 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 · runnablegraph.whisper.securitySign in to run
// 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 · runnablegraph.whisper.securitySign in to run
// 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.

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.

Key concepts: Indicator of compromise · ASN reputation · Reconciled verdict · TLS fingerprint.

Going deeper

  • More patterns — Cross-Layer Patterns 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, and full procedure signatures in Procedures.
  • Feeds behind the verdict — Threat Feeds & Categories 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 and MCP Setup.

Splunk equivalents

Enriching events inline rather than running ad-hoc Cypher? The same workflows in SPL: Splunk Use Cases for Infrastructure Intel. For whisperlookup and whisperquery see Search Commands.