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.
On this page (32)
- First 30 seconds: is this thing bad?
- Reconciled verdict — one blocking-aware answer
- explain() — the verdict's evidence chain
- Coverage-qualified verdict — "no-data ≠ benign"
- Which feeds, and what kind of bad
- Which threat categories put this IP on a feed?
- Which feeds still list it once you exclude a category you already know about?
- Network attribution & GeoIP
- Trace an IP to its network owner
- Who really operates this netblock?
- Look up GeoIP location
- Country via BGP when GeoIP is empty
- Co-hosted infrastructure & blast radius
- Reverse DNS: what else is hosted here?
- Count co-hosted domains before pivoting
- Neighborhood toxicity — threat density per prefix
- Pivot the campaign: egress, fingerprints, certificates
- Is this a Tor exit, and which relay?
- Track C2 across changing domains via TLS fingerprint
- What is this TLS fingerprint hash?
- Discover subdomains from Certificate Transparency
- Catch the lookalike domain behind the lure
- WHOIS, DNS & evidence collection
- Quick WHOIS check
- Has the registrar changed? (WHOIS history)
- Who controls DNS?
- De-cloak the real origin behind a CDN
- Full infrastructure trace for the report
- Batch IOC enrichment
- Hit it from the command line
- Going deeper
- Splunk equivalents
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.
// 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):
[{
"score": 16.84, "level": "LOW", "block": false, "coverage": "known-clean",
"ip.isC2": false, "ip.isMalware": false,
"ip.isTor": true, "ip.isAnonymizer": true
}]
Tip: Read
verdictCoveragefirst,verdictLevelsecond, and the rawthreatScorelast. 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/verdictBlockingare the reconciled triage signals — prefer them over rawthreatScore. There is alsoverdictAdvisory, but it carries a note only in specific cases (1.1.1.1returnsallowlist-vouched) and reads null on most indicators, so read it if you select it and never gate on it. The booleanis*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) readINFOandfalseon the verdict surfaces by design; the rawthreatScoreis never clamped, soWHERE ip.threatScore > 5still matches them. IfverdictLevelisNONE, 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.
// 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):
[{
"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 sand filterWHERE s.weight >= 1.0— everything below the floor is corroboration, everything above it is the case.explainalso 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.
// 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):
[{
"host": "github.com", "canonical_name": "Github",
"host_class": "multi_tenant_user_content",
"roles": ["DNS_OPERATOR", "MAIL_RECEIVER", "ORIGIN_AS"], "confidence": 0.85
}]
// 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):
[{
"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
coveragebeforeband. 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.assessalso 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.
bandtells you how bad.coveragetells 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.
coverage | What it means | What 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:
| 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 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-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.
// 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):
[
{"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.nameis the slug (greensnow),displayNameis the human name, andCATEGORY.id(tor,c2,phishing) is what you filter on.MATCH (c:CATEGORY) RETURN c.id, c.displayName LIMIT 25lists 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.
// 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):
[{"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
WHEREis 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
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.
// 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:
[{"ip": "104.16.132.229", "prefix": "104.16.128.0/20", "asn": "AS13335", "network": "CLOUDFLARENET - Cloudflare, Inc."}]
Tip:
ANNOUNCED_BYreflects current BGP routing, so you always get the live announcement.ROUTESis undirected — it matches whichever arrow you write. For IPs with no live announcement,BELONGS_TOgives the registered allocation block instead. Anchor network analytics (asRank,coneAsns,routeLeakCount) on the:ASNnode, never on theASN_NAMEnode, 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.
// 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:
[{"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.
// 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:
[{"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.
// 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:
[{"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.
// 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:
[
{"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
// 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):
[{"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 plaincount()here, notcount(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.
// 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):
[{"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:
// 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):
[{"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.
// 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:
[
{"ip.name": "185.220.101.1", "relay_fingerprint": "6c64100d8f7050e76f420ce404031eabc7101124"},
{"ip.name": "185.220.101.1", "relay_fingerprint": "8f744605199e75c26f74e818bde50d9a7325ec94"}
]
Tip: Pair this with the
isTor/isAnonymizerflags 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.
// 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 throughexplain()before calling it related. A rare fingerprint shared by a handful of IPs is the strong signal. To find a live anchor, runMATCH (ip:IPV4)-[:EMITS_TLS_FINGERPRINT]->(f:TLS_FINGERPRINT) RETURN ip.name, f.name LIMIT 5and 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.
// 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):
[{"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
categoryofBENIGNwith a namedlabelis the useful answer — it says the handshake belongs to a common client build, so a match on it is not evidence.familyandvendorare 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.
// 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):
[
{"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.
// 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: truemeans registered, not malicious — pivot each hit straight throughexplain(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 thedomain_variantsMCP tool.
WHOIS, DNS & evidence collection
Quick WHOIS check
You need registration details for a suspicious domain — registrar, contact emails, phones.
// 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:
[{
"h.name": "cloudflare.com",
"registrars": ["iana:1910"],
"emails": ["domains@cloudflare.com", "noreply@data-protected.net"],
"phones": ["+10000000000", "+16503198930"]
}]
Tip: Use
OPTIONAL MATCHfor WHOIS fields — not every domain has every field. A plainMATCHwould drop the whole row for a partially-registered domain. To pivot to siblings sharing a registrant email, reverseHAS_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.
// 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):
[
{"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, andwhisper.history.bgp(ip|asn|prefix)always emits the same routing columns, so a fixedYIELDnever breaks between calls. The generalwhisper.history(indicator)picks the shape from the indicator at runtime, which is fine by hand but not from a script. Keep aLIMITon 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.
// Authoritative nameservers for a domain
MATCH (ns:HOSTNAME)-[:NAMESERVER_FOR]->(h:HOSTNAME {name: "google.com"})
RETURN ns.name LIMIT 10
Sample output:
[
{"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.
// 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):
[
{"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:
confidenceis a0.0–1.0scale andmethods[]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 lonemxorspfhit 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 atWHERE confidence >= 0.4to keep sibling-grade and corroborated candidates; raise the floor to0.5when 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.
// 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:
[
{"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.
// 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):
[
{"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:
UNWINDhandles hundreds of indicators per query. To run the full scored verdict on each, chainCALL explain(ip_addr)after theUNWIND— but eachexplain()is a separate backend call, so keep that list modest. ReadingverdictLevel/verdictBlockingstraight off the node is the cheaper batch path. For a verdict with coverage, or owner, country and network per indicator, hand the whole list towhisper.assessorwhisper.enrichinstead — 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.
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: Beareralso 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.