Indicator Triage (SOC)
From a raw IP or domain to a reconciled verdict, network attribution, co-hosted blast radius, and a paste-ready evidence chain.
On this page (28)
- 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"
- 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
- 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.isC2, ip.isMalware, ip.isTor, ip.isAnonymizer
Sample output (captured 2026-08-09 — scores are live reads and move with the feeds):
[{
"score": 16.84, "level": "LOW", "block": false,
"ip.isC2": false, "ip.isMalware": false,
"ip.isTor": true, "ip.isAnonymizer": true
}]
Tip:
verdictScore/verdictLevel/verdictBlockingare the reconciled triage signals — prefer them over rawthreatScore. There is a fourth,verdictAdvisory, 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. 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.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-08-09):
[{
"host": "github.com", "canonical_name": "Github",
"host_class": "multi_tenant_user_content",
"roles": ["DNS_OPERATOR", "MAIL_RECEIVER", "ORIGIN_AS"], "confidence": 0.8
}]
// Is it dangerous — qualified by coverage (gate on this, don't trust an empty band)
CALL whisper.assess(["github.com"])
YIELD host, band, coverage, sub_labels, signals
RETURN host, band, coverage, sub_labels, signals
LIMIT 5
Sample output (measured 2026-08-09):
[{
"host": "github.com", "band": "INFO", "coverage": "known-clean", "sub_labels": [],
"signals": [
{"source": "reconciler", "kind": "multi-tenant-apex", "confidence": 1.0},
{"source": "url-path-listing", "kind": "path-listings", "count": 2, "listings": [
{"path": "/oven-sh/bun/releases/download/", "band": "NONE", "categories": []},
{"path": "/dm-7926/dz-nvd/raw/main/m1n.zip", "band": "HIGH", "categories": ["malware"]}
]}
]
}]
Read that row carefully, because it is the whole lesson: the apex is known-clean — coverage: known-clean. In coverage, no malicious evidence. at INFO, and one path under it is HIGH for malware. 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.
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 and whisper.assessUrl return coverage. whisper.explain does not.
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 | No | Returns score, level, explanation, factors and sources. There is no coverage column, so a NONE level from explain() is not a clean verdict. |
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.
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. With flat tools that's three lookups glued together; here it's one hop chain.
// 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.
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:
[{"cohosted": 1488}]
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.
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:
[{"prefix": "45.148.10.0/24", "toxic_neighbors": 139}]
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:
[{"prefix": "185.220.101.0/24", "toxic_neighbors": 150}]
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.
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.
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
Coverage on this plane is partial. 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.
Discover subdomains from Certificate Transparency
A phishing kit registers secure-login.victim-corp.example and gets a cert — which lands in CT logs the moment it's issued, often before DNS resolves. 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: "ebay-verify.info"})-[:SEEN_IN_CT]->(ct:CT_OBSERVATION)
RETURN ct.name AS ct_observation
LIMIT 25
If this returns nothing — Certificate Transparency coverage is partial.
github.comhas none.paypal.comhas 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() returns the timestamped WHOIS trail in one call.
// WHOIS history — registrar transfers, registrant changes
CALL whisper.history("google.com")
YIELD createDate, updateDate, registrar, registrant, nameServers
RETURN createDate, updateDate, registrar, registrant, nameServers
LIMIT 3
Tip:
whisper.historyneeds a key (not on the anonymous tier). For an IP, ASN, or prefix the same call returns BGP routing history instead — keep aLIMITon it and expect a longer round trip; BGP history over a large network can take many seconds.
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
Tip: Results are ranked highest-confidence first, each with the
methods[]that found it so you can weigh the evidence. 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:
[
{"ip_addr": "185.220.101.1", "prefix": "185.220.101.0/24", "asn": "AS60729", "level": "HIGH", "block": true},
{"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": "NONE", "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.
Hit it from the command line
Everything above is one HTTP POST. Anonymous works for a 2-hop sanity check; a free key unlocks 3.
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 a free-key header (
-H "X-API-Key: $WHISPER_KEY";Authorization: Beareralso works) to run the 3-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.
Going deeper
- More patterns — Cross-Layer Patterns has the copy-paste pivots that apply across every use case, including batch existence checks 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 76 feeds and 31 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.