Using It
What the add-on does once it is installed: the five search commands and the field contract each one returns, everything the enrichment pipeline reaches in the graph, and the three modular inputs that fill your index on a schedule.
On this page (33)
- How enrichment works
- Search commands
- whisperlookup
- whisperquery
- whisperschema
- whisperflush
- whisperevict
- What enrichment reaches
- Domain and IP infrastructure
- Threat intelligence
- WHOIS
- GeoIP
- Prefix and BGP hijack detection
- Web link graph
- CNAME chains and nameservers
- Caching and pre-computed enrichment
- Performance
- Modular inputs
- ES Threat Intelligence Feed
- Attack Surface Baseline
- Watchlist Enrichment
- Scheduling
- Worked examples
- Rank firewall traffic by infrastructure
- Find Tor exit nodes in your traffic
- Find traffic to bulletproof hosting
- Corroborate an indicator across feeds
- Map and monitor your own DNS surface
- Investigate one indicator
- Attribute by registration data
- Audit SPF across your domains
- Write your own risk events
- The same questions in Cypher
Using It Documentation
Installed and credentialed, the add-on gives you two things: five search commands in the search bar, and three modular inputs that run on a schedule. whisperlookup is the one you will type most — a streaming command that enriches events inline, in the middle of a pipeline. The other four generate rather than stream: ad-hoc Cypher, schema inspection, and two cache-management commands. The inputs write to an index and to KV Store collections, so a dashboard or an alert can read infrastructure context without waiting on a live call.
Getting to that point is Install and configure. The complete census of shipped objects — every macro, saved search, lookup, sourcetype and collection — is the add-on reference.
Read
coveragebeforeband. Only known-clean — coverage: known-clean. In coverage, no malicious evidence. licenses the word "clean"; no-data — coverage: no-data. Not in coverage. This is not a verdict — nothing was looked at. means unknown, which is a different thing again; malicious-evidenced — coverage: malicious-evidenced. In coverage, with positive evidence of malice. and ambiguous — coverage: ambiguous. In coverage, and the evidence points both ways. mean there is evidence, whatever the band says.whisper.explaindoes not returncoverageat all. Full contract: Coverage — what we looked at.
How enrichment works
Every event that passes through whisperlookup goes through the same five stages:
- Type detection — decides whether the indicator is an IP (IPv4 regex) or a domain
- Cache check — looks in the
whisper_enrichment_cacheKV Store collection for a cached result - API enrichment — queries WhisperGraph with parameterized Cypher
- Field mapping — maps graph results to
whisper_-prefixed field names - Event output — appends the enrichment fields to the original event
Four modules do the work:
| Module | Role |
|---|---|
whisper_enrichment.py | Orchestrates the pipeline: type detection, cache check, API call, field mapping |
whisper_enrichment_queries.py | Builds parameterized Cypher queries for domain and IP enrichment |
whisper_enrichment_parsers.py | Parses API responses into flat dictionaries |
whisper_field_mapper.py | Maps parsed results to whisper_-prefixed and CIM-aliased fields |
All API calls go through WhisperAPIClient, which handles retries and connection pooling. Results are cached by whisper_cache.py.
Search commands
| whisperlookup | Inline enrichment of events in SPL |
| whisperquery | Raw Cypher queries against WhisperGraph |
| whisperschema | Inspect the graph schema (node labels, relationship types, properties) |
| whisperevict / whisperflush | Cache management |
whisperlookup
Streaming command that enriches events with IOC context from WhisperGraph, appending enrichment fields to each event inline.
| whisperlookup field=<field_name> [type=auto|domain|ip] [include_threat_intel=true|false] [include_cname=true|false] [include_nameserver=true|false] [include_feeds=true|false] [add_prefix=<prefix>] [use_cache=true|false]
| Parameter | Required | Default | Description |
|---|---|---|---|
field | Yes | — | Event field containing the indicator to enrich |
type | No | auto | Indicator type: auto (detect from value), domain, or ip |
include_threat_intel | No | true | Include all threat-related fields (threat score, level, boolean indicators, ASN threat data, and explain API results) |
include_cname | No | true | Include CNAME chain resolution |
include_nameserver | No | true | Include nameserver information |
include_feeds | No | true | Include threat feed listings |
add_prefix | No | whisper_ | Prefix added to enrichment field names |
use_cache | No | true | When false, bypasses the KV Store enrichment cache and precomputed collection for this search (always calls the live API) |
Output fields
The fields below are appended to each event with the configured prefix. Read the Guarantee column before you write a rule against any of them. One field is guaranteed on every event the command enriched; every other field is conditional on a parameter you can switch off, on the graph holding that kind of data for the indicator, or on both. A field can therefore be missing for three different reasons — the toggle that produces it was off, the graph has nothing for the indicator, or the call did not complete — and the event looks the same in all three cases. A rule must test for presence, and must never read a missing field as a negative finding.
For domain enrichment:
| Column | Type | Guarantee | When absent | What a rule must do |
|---|---|---|---|---|
whisper_type | string | Guaranteed on any enriched event — the indicator type the command used (domain or ip) | Enrichment did not run for the event at all: a private address, or a value the command could not type | Use it to tell "not enriched" apart from "enriched and empty" |
whisper_ip | multivalue string | Conditional — the domain resolves in the graph | No A/AAAA record, or the domain is not covered | Do not read absence as "does not resolve" |
whisper_prefix | string | Conditional — a resolved IP falls inside an announced prefix | The domain did not resolve, or the IP is not routed | Skip the routing branch rather than defaulting it |
whisper_asn | string | Conditional — the resolved IP is routed | As above | Key routing rules on this field, not on the ASN name |
whisper_asn_name | string | Conditional — the ASN carries a name | Unrouted IP, or an ASN with no name edge | Treat as display only; a missing name is not an unknown network |
whisper_country | string | Conditional — GeoIP places the resolved IP | No GeoIP record for the IP | Do not fall back to the ASN's country; anycast makes that wrong |
whisper_cohost_count | number | Conditional — the resolved IP is known to host other domains | The IP did not resolve, or nothing else is known on it | Absent is not 0. Test presence before thresholding |
whisper_cname_chain, whisper_cname_depth, whisper_cname_target | multivalue string, number, string | Conditional — include_cname=true (the default) and the domain is aliased | The toggle is off, or the domain has no CNAME | Check the toggle before concluding the domain is not aliased |
whisper_nameservers | multivalue string | Conditional — include_nameserver=true (the default) and NS records exist | The toggle is off, or no NS data | As above |
whisper_threat_score | number (float, 0-100+) | Conditional — include_threat_intel=true (the default) and the graph holds threat data for the indicator | The toggle is off, or the indicator is not covered | Absent is not 0. A fillnull value=0 whisper_threat_score turns "unknown" into "clean" |
whisper_threat_level | string — one of NONE, INFO, LOW, MEDIUM, HIGH, CRITICAL | Conditional — same condition as the score; derived from the score when the API returns null | The toggle is off, or the indicator is not covered | NONE means "not covered", not "clean". Measured against production on 2026-08-09: 203.0.113.10, a reserved address that hosts nothing at all, comes back NONE with score 0.0 — the same answer a genuinely clean host gives. Never let a rule read NONE or absence as a positive clean verdict |
whisper_is_threat, whisper_is_tor, whisper_is_c2, whisper_is_malware, whisper_is_phishing, whisper_is_spam, whisper_is_bruteforce, whisper_is_scanner, whisper_is_blacklist, whisper_is_proxy, whisper_is_vpn, whisper_is_anonymizer, whisper_is_whitelist | boolean | Conditional — include_threat_intel=true and the indicator carries that flag | The toggle is off, or the indicator has no threat record | A missing whisper_is_c2 means "no C2 evidence", never "not C2" |
whisper_threat_explanation | string | Conditional — the explain() path ran (see Threat intelligence) | The inline graph properties answered, so explain() was never called | Never make an alert body depend on it |
whisper_threat_factors | multivalue string | Conditional — the indicator has contributing factors | Not covered, or no factor fired | Absence is not "no factors found" |
whisper_threat_sources | structured list | Conditional — include_feeds=true (the default) and the indicator is listed | The toggle is off, or the indicator is on no feed | Use whisper_feed_count for counting, not the length of this field |
whisper_threat_sources_count | number | Conditional — same condition; how many threat-intelligence sources list the indicator | As above | Absent is not 0 |
whisper_threat_feed_ids | multivalue string | Conditional — same condition; the feed ids ES uses as threat_key | As above | Do not construct a threat_key when it is missing |
whisper_threat_first_seen, whisper_threat_last_seen | date | Conditional — the indicator is listed on at least one feed; the earliest and the most recent date it appeared on any of them | Not listed, or listed with no dates | A missing last_seen is not "seen today" |
whisper_feed_names, whisper_feed_count, whisper_feed_categories | multivalue string, number, multivalue string | Conditional — include_feeds=true (the default) and the indicator is listed | The toggle is off, or the indicator is on no feed | whisper_feed_count absent is not 0 |
whisper_risk_score | number (0-100) | Conditional — computed from the fields above, so it needs the threat data they need | The inputs are absent | Do not compare it against a score the add-on did not compute |
whisper_risk_level | string — one of informational, low, medium, high, critical | Conditional — same condition as whisper_risk_score | The inputs are absent | Match on the exact lowercase value; this is a different vocabulary from whisper_threat_level |
whisper_risk_factors_list | multivalue string | Conditional — at least one risk factor fired | No factor fired, or the inputs are absent | Absence means no factor fired or nothing to score |
whisper_risk_components | JSON | Conditional — same condition as whisper_risk_factors_list | As above | Parse defensively; the key set follows the factors that fired |
For IP enrichment, the same fields apply, plus:
| Column | Type | Guarantee | When absent | What a rule must do |
|---|---|---|---|---|
whisper_reverse_dns_count | number | Conditional — the IP has reverse DNS in the graph | No PTR data for the IP | Absent is not 0 |
whisper_asn_threat_level | string — one of NONE, INFO, LOW, MEDIUM, HIGH, CRITICAL | Conditional — include_threat_intel=true and the IP is routed by a known ASN | The toggle is off, or the IP is unrouted | Same rule as the indicator level: NONE is "not covered" |
whisper_asn_threat_score | number | Conditional — same condition; the ASN's composite score | As above | Absent is not 0 |
whisper_asn_max_threat_score | number | Conditional — same condition; the highest single-prefix score inside the ASN | As above | A high value describes the ASN's worst prefix, not this IP |
whisper_asn_avg_threat_score | number | Conditional — same condition; averaged across the ASN's prefixes | As above | Do not use it as a verdict on a single address |
whisper_asn_has_threatening_prefixes | boolean | Conditional — same condition | As above | Absent means "not established", not false |
The families beyond this core — WHOIS, city-level GeoIP, hostname-level threat properties, prefix threat, BGP hijack detection and the web link graph — are listed under What enrichment reaches with the graph paths they come from.
Do not key a rule on the CIM name.
whisperlookupis a streaming command: its output keeps the caller's sourcetype, so theFIELDALIASentries declared under[whisper:enrichment]never fire on it.| whisperlookup … | where threat_score > 50returns nothing. Filter onwhisper_threat_score. The alias table, and which aliases are real, is on the add-on reference.
Examples
Enrich firewall logs with domain context:
index=firewall sourcetype=pan:traffic
| whisperlookup field=dest_host type=domain
| where whisper_threat_score > 50
| table _time dest_host whisper_asn_name whisper_threat_level whisper_feed_names
Enrich IP addresses with auto-detection:
index=proxy sourcetype=squid
| whisperlookup field=src_ip
| stats count by whisper_asn_name whisper_country
Domain enrichment without threat intel (faster):
index=dns sourcetype=dns
| whisperlookup field=query type=domain include_threat_intel=false include_feeds=false
| table query whisper_ip whisper_asn whisper_asn_name whisper_cohost_count
Custom field prefix:
index=web sourcetype=access_combined
| whisperlookup field=clientip type=ip add_prefix="w_"
| table clientip w_asn_name w_country w_threat_level
Private IP addresses: Private IP addresses (RFC 1918: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) are skipped automatically — no API call is made and no enrichment fields are added.
whisperquery
Generating command that runs ad-hoc Cypher queries against WhisperGraph.
| whisperquery query="<cypher_query>" [params="<parameters>"] [params_b64="<base64_json>"] [max_results=<N>] [validate_indicator="<param_name>"]
| Parameter | Required | Default | Description |
|---|---|---|---|
query | Yes | — | Cypher query string (must include LIMIT clause) |
params | No | — | Query parameters as key=value,key2=value2 or JSON string |
params_b64 | No | — | Base64-encoded JSON parameters (avoids SPL quote-escaping issues with arrays) |
max_results | No | 10000 | Maximum number of rows to return |
validate_indicator | No | — | When set to a parameter name (e.g. indicator), applies the strict allowlist ^[A-Za-z0-9._:\-]+$ to parameters[<name>], lowercases it, and short-circuits with zero events on missing/empty/invalid input before executing any Cypher. Use this in dashboards where the parameter value originates from user input (e.g. $indicator_input$) — the Investigation dashboard uses validate_indicator="indicator" on every pivot panel to replace the legacy SPL | where match(...) guard. |
Output fields
whisperquery has no field contract of its own. Its output shape is your RETURN clause: one event per row, one field per returned column, named by the alias you gave it. Two things follow, and both bite in scheduled searches:
- Alias every column.
RETURN h.name AS hostnameproduceshostname.RETURN hproduces whatever the node serialises to, which is not a contract you can write a rule against. - A column can be null on every row without anything failing. Selecting a property the matched nodes do not carry is not an error — measured on production 2026-08-09,
MATCH (r:ROA) RETURN r.name AS roa_name, r.prefix AS prefix LIMIT 3returns three rows withroa_namenull throughout andprefixpopulated. A rule keyed on that column matches nothing and looks like a quiet environment. Run a query once and read its columns before you schedule it.
Examples
Look up a domain's infrastructure:
| whisperquery query="MATCH (h:HOSTNAME {name: $domain})-[:RESOLVES_TO]->(ip:IPV4)-[:BELONGS_TO]->(p:PREFIX)<-[:ROUTES]-(a:ASN)-[:HAS_NAME]->(n:ASN_NAME) RETURN h.name AS hostname, ip.name AS ip, p.name AS prefix, a.name AS asn, n.name AS asn_name LIMIT 10" params="domain=example.com"
Find co-hosted domains:
| whisperquery query="MATCH (h:HOSTNAME {name: $domain})-[:RESOLVES_TO]->(ip:IPV4)<-[:RESOLVES_TO]-(cohost:HOSTNAME) WHERE cohost.name <> $domain RETURN ip.name AS ip, cohost.name AS cohost LIMIT 100" params="domain=example.com"
Get ASN routing information:
| whisperquery query="MATCH (a:ASN {name: $asn})-[:ROUTES]->(p:PREFIX) RETURN a.name AS asn, p.name AS prefix LIMIT 200" params="asn=AS13335"
Use JSON parameters:
| whisperquery query="MATCH (h:HOSTNAME {name: $domain}) RETURN h LIMIT 1" params='{"domain": "example.com"}'
Write operations are blocked: Queries containing
CREATE,DELETE,SET,MERGE,DROP,REMOVE, orDETACHkeywords are rejected before being sent to the API. WhisperGraph is read-only.
whisperschema
Generating command that shows the graph schema — node labels, relationship types, property keys, and metadata (descriptions, examples, counts, query patterns).
| whisperschema mode=<labels|relationships|properties|schema|full>
mode is optional and defaults to labels.
| Mode | Description | Cypher Equivalent |
|---|---|---|
labels | List all node labels | CALL db.labels() |
relationships | List all relationship types | CALL db.relationshipTypes() |
properties | List all property keys in the graph | CALL db.propertyKeys() |
schema | Schema with descriptions, examples, counts, fast/slow patterns | CALL db.schema() |
full | Combined schema + property keys | CALL db.schema() + CALL db.propertyKeys() |
All events include a whisper_schema_mode field for filtering.
Output fields
When using mode=schema or mode=full, every event carries every column below — the Splunk chunked v2 protocol needs a uniform field set for fields to display correctly. That makes this the one command on the page whose columns are guaranteed present; what varies is whether they hold a value. A column that does not apply to an event type arrives as an empty string, never missing, so "absent" and "empty" are the same signal here and neither means zero.
| Column | Type | Guarantee | When absent | What a rule must do |
|---|---|---|---|---|
type | string | Guaranteed, always populated — node, relationship, or tips | Never | Filter on it first; the other columns only make sense per type |
name | string | Guaranteed, always populated — the label or relationship name (HOSTNAME, RESOLVES_TO) | Never | Safe to join on |
count | number | Guaranteed as a column; populated for node and relationship events | Empty string on a tips event | Cast before comparing — an empty string is not 0 |
description | string | Guaranteed as a column; populated where the schema carries one | Empty string when the entity has no description | Display only |
example | string | Guaranteed as a column; populated where the schema carries one | Empty string when the entity has no example | Display only |
sourceLabels | JSON array as a string | Guaranteed as a column; populated on relationship events | Empty string on node and tips events | Parse only after filtering type="relationship" |
targetLabels | JSON array as a string | Guaranteed as a column; populated on relationship events | Empty string on node and tips events | As above |
fastPatterns | JSON array as a string | Guaranteed as a column; populated on relationship and tips events | Empty string on node events | As above |
slowPatterns | JSON array as a string | Guaranteed as a column; populated on relationship and tips events | Empty string on node events | As above |
bestPractices | JSON array as a string | Guaranteed as a column; populated on relationship and tips events | Empty string on node events | As above |
Examples
Explore the schema with descriptions and examples:
| whisperschema mode=schema
| search type=node
| table name, count, description, example
View query best practices:
| whisperschema mode=schema
| search type=tips
| table bestPractices, fastPatterns, slowPatterns
Get combined schema and property keys:
| whisperschema mode=full
| stats count by whisper_schema_mode
mode=labels, mode=relationships and mode=properties take no other arguments and are run on their own.
whisperflush
Generating command that flushes the enrichment cache.
| whisperflush [collection=cache|precomputed|all]
collection is optional and defaults to cache.
| Collection | KV Store | Description |
|---|---|---|
cache | whisper_enrichment_cache | TTL-based enrichment cache |
precomputed | whisper_precomputed_enrichment | Pre-computed watchlist enrichments |
all | Both | Flush both cache and precomputed collections |
There is no role gate on this command. Older documentation said
whisperflushrequiresadminorsc_admin. The shipped command performs no capability, role or authorization check of its own (F-SP-16) — anyone who can run it can flush a cache the whole search head shares. If that matters in your environment, gate it with Splunk's own capability controls rather than relying on the command.
Output fields
whisperflush emits a status row — which collection it flushed and whether the flush succeeded — rather than data. The exact column names on that row are UNVERIFIED on this page: they are not stated by any artifact the docs can cite, and we would rather say so than publish a list nobody has checked. Run | whisperflush | fieldsummary once against your own install and key any automation on what comes back.
Examples
| whisperflush
| whisperflush collection=precomputed
| whisperflush collection=all
whisperevict
Generating command that evicts expired entries from the enrichment cache through the KV Store REST API. It takes no arguments.
| whisperevict
It works in two phases. First a bulk delete: it queries the KV Store for entries expired under the default TTL and deletes them in a single REST call. Then it scans entries carrying a non-default TTL and deletes the expired ones individually. Neither phase loads cache records into the search pipeline, which is what makes it usable on a large cache — and it is what the shipped "Whisper - Evict Expired Cache Entries" saved search runs.
Output fields
| Column | Type | Guarantee | When absent | What a rule must do |
|---|---|---|---|---|
collection | string | Guaranteed — the KV Store collection the run targeted | Never on a completed run | Group by it when you run eviction on more than one collection |
action | string | Guaranteed — always evict_expired | Never on a completed run | Use it to tell these events apart from other status rows |
status | string — success, skipped, or error | Guaranteed | Never on a completed run | Branch on it first. skipped is not success: nothing was examined |
evicted | number | Conditional — present when the run examined the collection | Absent or empty when status is error | 0 is a real answer meaning nothing had expired. Absence means the run did not get that far — do not chart the two as the same point |
ttl_seconds | number | Conditional — the TTL the run used to decide what had expired | Absent when status is error | Read it before concluding entries were kept too long; a changed TTL explains a changed eviction count |
error | string | Conditional — present only when status is error | Absent on success and on skipped | Never alert on the presence of error alone; alert on status, and use this for the message |
What enrichment reaches
One indicator fans out across several layers of the graph. Each layer below names the path the enrichment queries walk and the fields it produces; every one of them is conditional in the sense whisperlookup's field contract describes.
Domain and IP infrastructure
Domain enrichment runs in two stages — resolve the hostname to its addresses, then look up BGP context for the first resolved IP along the same ANNOUNCED_BY path IP enrichment uses:
Stage 1: HOSTNAME → RESOLVES_TO → IPV4
Stage 2: IPV4 → ANNOUNCED_BY → PREFIX ← ROUTES ← ASN
(then: ASN → HAS_NAME, ASN → HAS_COUNTRY as separate single-hop queries)
IP enrichment is Stage 2 on its own, plus city-level geolocation:
IPV4 → ANNOUNCED_BY → PREFIX ← ROUTES ← ASN → HAS_NAME → ASN_NAME
ASN → HAS_COUNTRY → COUNTRY
Fields: whisper_ip, whisper_prefix, whisper_asn, whisper_asn_name, whisper_country, whisper_cohost_count, and for IPs also whisper_reverse_dns_count.
index=dns sourcetype=dns
| whisperlookup field=query type=domain
| table query whisper_ip whisper_prefix whisper_asn whisper_asn_name whisper_country
Threat intelligence
Threat data arrives by one of two paths, and which one ran changes what you get:
- Inline —
threatScore,isThreat,isTor,isC2and the rest are properties on the IPV4 node itself. The infrastructure queries above return them in the same round trip, so no extra call happens. explain()— a richer assessment with explanation text, contributing factors, per-feed sources and first/last seen dates. It is called only when the inline properties are absent (threat_scoreis null).
Both paths populate whisper_threat_score and whisper_threat_level. Only the explain() path populates whisper_threat_explanation, whisper_threat_factors, whisper_threat_breakdown (component scores), whisper_threat_available and whisper_threat_cached.
The boolean flags mean this:
| Field | Meaning |
|---|---|
whisper_is_threat | Known threat indicator |
whisper_is_tor | Tor exit node |
whisper_is_c2 | Command-and-control server |
whisper_is_malware | Malware distribution |
whisper_is_phishing | Phishing host |
whisper_is_spam | Spam source |
whisper_is_bruteforce | Brute-force source |
whisper_is_scanner | Network scanner |
whisper_is_blacklist | On a public blacklist |
whisper_is_proxy | Open proxy |
whisper_is_vpn | Known VPN exit |
whisper_is_anonymizer | Anonymization service |
whisper_is_whitelist | Explicitly whitelisted |
index=proxy sourcetype=squid
| whisperlookup field=dest_host include_threat_intel=true include_feeds=true
| where whisper_threat_score > 30
| table dest_host whisper_threat_level whisper_threat_score whisper_feed_names whisper_threat_explanation
Score range:
whisper_threat_scoreis an unbounded float (typically 0-100+), not a 0-1 fraction. As a rule of thumb, 50 and above is high confidence and 10 and above is moderate.
Domain enrichment also reads threat properties from the HOSTNAME node itself, independent of anything derived from the resolved IP. Those fields carry a hostname_ infix — whisper_hostname_threat_score, whisper_hostname_threat_level, and every is_* boolean in the table above as whisper_hostname_is_spam, whisper_hostname_is_proxy, whisper_hostname_is_vpn and so on. A domain can be clean at the hostname level and sit on a flagged IP, or the reverse; that is why both exist.
index=dns sourcetype=dns
| whisperlookup field=query type=domain include_threat_intel=true
| where whisper_hostname_threat_level="HIGH" OR whisper_hostname_threat_level="CRITICAL"
| table query whisper_hostname_threat_score whisper_hostname_threat_level
ASN reputation (whisper_asn_threat_level, whisper_asn_threat_score, whisper_asn_max_threat_score, whisper_asn_avg_threat_score, whisper_asn_has_threatening_prefixes) comes back with both IP and domain enrichment. These fields appear only when the API returned a non-null value, so filter with isnotnull(whisper_asn_threat_level) rather than comparing against a default.
WHOIS
Domain enrichment pulls registration data when the graph has it:
HOSTNAME → HAS_REGISTRAR → REGISTRAR
HOSTNAME → REGISTERED_BY → ORGANIZATION
HOSTNAME → HAS_EMAIL → EMAIL
HOSTNAME → HAS_PHONE → PHONE
HOSTNAME → PREV_REGISTRAR → REGISTRAR (previous)
| Field | Description |
|---|---|
whisper_registrar | Domain registrar name |
whisper_registrant_org | Registrant organization |
whisper_registrant_email | Registrant contact email |
whisper_registrant_phone | Registrant phone number |
whisper_registration_date | Domain registration date |
whisper_expiration_date | Domain expiration date |
whisper_prev_registrar | Previous registrar (registrar change detection) |
whisper_organization | Registrant organization via the REGISTERED_BY edge |
WHOIS coverage varies a lot by domain, and these fields come from OPTIONAL MATCH — an unavailable value is an absent field, not an empty one.
index=dns sourcetype=dns
| whisperlookup field=query type=domain
| table query whisper_registrar whisper_registrant_org whisper_registrant_email whisper_organization
GeoIP
IP enrichment adds city-level geolocation through IPV4 → LOCATED_IN → CITY. CITY nodes carry latitude, longitude and the country code embedded in the name.
| Field | Description |
|---|---|
whisper_geo_city | City name (e.g. "Mountain View") |
whisper_geo_country | Country code extracted from the city name (e.g. "US") |
whisper_geo_latitude | City latitude (decimal degrees) |
whisper_geo_longitude | City longitude (decimal degrees) |
An anycast address such as 1.1.1.1 may have no single LOCATED_IN edge at all, so these fields are absent for it. That absence is correct, not missing data.
index=firewall sourcetype=pan:traffic
| whisperlookup field=dest_ip type=ip
| table dest_ip whisper_geo_city whisper_geo_country whisper_geo_latitude whisper_geo_longitude
Prefix and BGP hijack detection
IP enrichment reads threat data from both prefix views of an address, then compares who announces it against who is registered to own it:
IPV4 → ANNOUNCED_BY → ANNOUNCED_PREFIX (BGP routing)
IPV4 → BELONGS_TO → REGISTERED_PREFIX (RIR allocation)
| Field | Description |
|---|---|
whisper_announced_prefix | BGP announced prefix name |
whisper_ap_threat_score, whisper_ap_threat_level, whisper_ap_is_threat | Threat assessment of the announced prefix |
whisper_registered_prefix | RIR registered prefix name |
whisper_rp_threat_score, whisper_rp_threat_level, whisper_rp_is_threat | Threat assessment of the registered prefix |
whisper_bgp_hijack_detected | Boolean: the announcing ASN differs from the registered ASN |
whisper_bgp_announcing_asn | ASN currently announcing the prefix via BGP |
whisper_bgp_registered_asn | ASN registered as the prefix owner with the RIR |
whisper_bgp_announced_prefix, whisper_bgp_registered_prefix | The two prefixes being compared |
A detected mismatch is the single heaviest contributor to whisper_risk_score — it means the address's traffic may be routed through a network that has no registration claim to it.
index=firewall sourcetype=pan:traffic
| whisperlookup field=dest_ip type=ip
| where whisper_bgp_hijack_detected="true"
| table dest_ip whisper_bgp_announcing_asn whisper_bgp_registered_asn whisper_bgp_announced_prefix
Web link graph
Domain enrichment reads the hyperlink layer in both directions:
HOSTNAME → LINKS_TO → HOSTNAME (outbound)
HOSTNAME ← LINKS_TO ← HOSTNAME (inbound)
| Field | Description |
|---|---|
whisper_linked_domains | Deduplicated list of every linked domain |
whisper_link_count | Total unique linked domains |
whisper_suspicious_link_count | Links to or from suspicious or threat-listed domains |
whisper_outbound_links | Domains this domain links to (up to 25) |
whisper_inbound_links | Domains that link to this domain (up to 25) |
A domain with many inbound links from established sites behaves differently from one nothing links to, or one linked only by flagged sites — the risk score reads both.
index=dns sourcetype=dns
| whisperlookup field=query type=domain
| where whisper_link_count > 0
| table query whisper_link_count whisper_outbound_links whisper_inbound_links
CNAME chains and nameservers
With include_cname=true (the default), enrichment follows HOSTNAME -[:ALIAS_OF]-> HOSTNAME up to five hops and returns whisper_cname_chain, whisper_cname_depth and whisper_cname_target. With include_nameserver=true, it reads HOSTNAME <-[:NAMESERVER_FOR]- HOSTNAME into whisper_nameservers as a comma-separated list. Switching either toggle off is indistinguishable downstream from the domain having no CNAME and no NS data, which is why the field contract insists you check the toggle before you conclude anything.
index=dns sourcetype=dns
| whisperlookup field=query include_cname=true
| where whisper_cname_depth > 0
| table query whisper_cname_chain whisper_cname_target whisper_cname_depth
Caching and pre-computed enrichment
Every enrichment result is written to the whisper_enrichment_cache KV Store collection, keyed by indicator + indicator_type. A repeat lookup of the same indicator inside the TTL window is served from KV Store with no API call.
| Setting | Default | Description |
|---|---|---|
| Cache TTL | 3600 seconds (1 hour) | How long a cached result stays valid |
| Cache collection | whisper_enrichment_cache | KV Store collection name |
The shipped Whisper - Evict Expired Cache Entries saved search calls | whisperevict hourly when enabled. To clear the cache outright:
| whisperflush collection=cache
For indicators that must answer instantly — an alert that cannot wait on a live call — pre-compute them instead. The Watchlist Enrichment input enriches everything in the whisper_watchlist collection on a schedule and stores the results in whisper_precomputed_enrichment, and whisperlookup checks that collection before it makes any live call. Set use_cache=false on a search that must bypass both.
Performance
| Scenario | Throughput | Notes |
|---|---|---|
| Cache hit | 5,000+ events/sec | KV Store lookup only, no API call |
| Cache miss (IP) | 10-30 events/sec | One API call per unique IP |
| Cache miss (domain) | 8-25 events/sec | Two-stage query (resolve + infrastructure) |
| Mixed (80% cache hit) | 500-2,000 events/sec | Typical production workload |
What moves those numbers, in the order worth trying:
- Filter before you enrich. A
whereorsearchahead ofwhisperlookupcuts the number of distinct indicators, which is the only thing that costs anything. - Name the type.
type=iportype=domainskips detection;type=autois a convenience, not a default worth keeping in a scheduled search. - Switch off what you are not reading.
include_threat_intel=false,include_cname=false,include_nameserver=falseandinclude_feeds=falseeach remove work from the query. - Watch the cache. Caching cuts API calls by 5-10x on repeated indicators, and
| inputlookup whisper_enrichment_cache | stats counttells you how large it has grown. - Pre-compute the indicators you alert on, so the alert never waits on a call at all.
- Reuse the shipped macros instead of re-deriving common pivots in SPL — they are listed in the add-on reference.
Modular inputs
Three modular inputs ship with the add-on. All are configured on the Inputs page in the add-on UI and all are disabled by default.
| Input | Writes | Default interval | Status |
|---|---|---|---|
| ES Threat Intelligence Feed | sourcetype=whisper:threat_intel, plus the whisper_ip_intel / whisper_domain_intel collections | 6 hours | Known issue — the collections do not seed (F-SP-2) |
| Attack Surface Baseline | whisper:attack_surface, whisper:spf_compliance, whisper:attack_surface_change, and risk events to index=risk | 24 hours | Live; the index=risk half needs Splunk ES (F-SP-15) |
| Watchlist Enrichment | sourcetype=whisper:watchlist, plus whisper_precomputed_enrichment | 4 hours | Live |
ES Threat Intelligence Feed
Populates the Splunk ES threat intelligence framework with scored indicators from the Whisper explain() API.
Status: Known issue — the collections do not seed (
F-SP-2). On first run, with both collections empty, the input seeds itself by asking the graph for IPV4 and HOSTNAME nodes carryingthreatScore > 0. That is an unfiltered label scan over the whole graph and it does not return rows against production — the same shape, and the same result, as the two disabled populator searches described under Splunk ES. Neither collection fills on its own today. Engineering owns the fix. The section stays because being told which step fails beats following it into silence.
| Setting | Default | Range | Description |
|---|---|---|---|
| Interval | 21600s (6 hr) | 300-86400 | Collection frequency |
| Max Indicators | 10000 | 1-100000 | Indicators per run |
| Include Infrastructure | off | — | Add ASN/country/prefix context |
| Account | required | — | Whisper API account |
| Index | whisper | — | Destination index |
Output: sourcetype=whisper:threat_intel
The input maintains two KV Store collections that ES consumes through the threat intelligence framework; correlation searches reference them automatically once they hold records:
| Collection | Key Field | Description |
|---|---|---|
whisper_ip_intel | ip | IP indicators with threat scores, ASN, country |
whisper_domain_intel | domain | Domain indicators with threat scores |
After the first run the input re-assesses whatever is already in the collections on each interval. Since the automatic seeding does not complete, put indicators in yourself — outputlookup is plain Splunk and does not depend on the graph query that fails:
| makeresults
| eval ip="203.0.113.50", description="Suspicious IP from investigation"
| outputlookup whisper_ip_intel append=true
| makeresults
| eval domain="malicious-example.com", description="Phishing domain"
| outputlookup whisper_domain_intel append=true
Check what landed:
| inputlookup whisper_ip_intel | head 10
| inputlookup whisper_domain_intel | head 10
Attack Surface Baseline
Collects DNS infrastructure snapshots for the domains you name and emits change events when a snapshot moves. The add-on ships no correlation search for those changes: the input writes the change and risk events itself, tagging them Whisper - DNS Infrastructure Change Detection.
| Setting | Default | Range | Description |
|---|---|---|---|
| Interval | 86400s (24 hr) | 3600-604800 | Collection frequency |
| Domains | required | — | Comma-separated domain list |
| Account | required | — | Whisper API account |
| Index | whisper | — | Destination index |
Enter the domains you want to monitor as a comma-separated list in the Domains field:
example.com, corp.example.com, subsidiary.com
The input discovers each domain's full DNS surface, so you do not list subdomains individually:
| Record Type | Cypher path | Description |
|---|---|---|
| A | RESOLVES_TO → IPV4 | DNS A records |
| NS | NAMESERVER_FOR → HOSTNAME | Nameservers |
| MX | MAIL_FOR → HOSTNAME | Mail servers |
| CNAME | ALIAS_OF → HOSTNAME (up to five hops) | CNAME chains |
| SUBDOMAIN | CHILD_OF → HOSTNAME (up to 1000) | Subdomains |
Outputs:
| Sourcetype | When emitted | Purpose |
|---|---|---|
whisper:attack_surface | Every run | Per-record DNS baseline (one event per A/NS/MX/CNAME/SUBDOMAIN record) |
whisper:spf_compliance | Every run | One event per domain with SPF record analysis |
whisper:attack_surface_change | Second run onward | Diff between the previous run's snapshot and the current one; one event per added or removed record |
index=risk (sourcetype stash) | When NS, MX or wildcard records change | High-priority risk events with MITRE ATT&CK technique annotations, for ES Risk-Based Alerting |
Two things to know before you build on those outputs:
index=riskis created by Splunk ES, not by this add-on (F-SP-15). The input writes to it unconditionally, so on an install without ES every one of those risk events is dropped. Everything else the input writes lands in yourwhisperindex regardless.whisper:attack_surface_changehas no field-extraction stanza of its own (F-SP-9).props.confdeclares[whisper:change], which nothing emits, so the JSON keys the change events carry —record_type,change_type,risk_score,mitre_technique— are configured under the wrong sourcetype name. Extract them in-search until that is fixed.
The input keeps a per-domain snapshot in its checkpoint after each run and compares the next run against it, so the first run after install produces baseline events only; change detection starts on the second. Each record is also written to the whisper_dns_baseline KV Store collection, which is what seeds the watchlist input below.
`whisper_index` sourcetype="whisper:attack_surface" | stats count by domain, record_type
Watchlist Enrichment
Pre-computes enrichment for a list of indicators you choose and stores the results in KV Store, so whisperlookup answers for them without a live call.
| Setting | Default | Range | Description |
|---|---|---|---|
| Interval | 14400s (4 hr) | 300-86400 | Enrichment frequency |
| Max Indicators | 10000 | 1-100000 | Indicators per run |
| Account | required | — | Whisper API account |
| Index | whisper | — | Destination index |
Output: sourcetype=whisper:watchlist, and the results themselves in whisper_precomputed_enrichment.
The input enriches everything in the whisper_watchlist KV Store collection. Each record has three fields:
| Field | Required | Description |
|---|---|---|
indicator | yes | Domain name or IP address |
indicator_type | no | "domain" or "ip" (auto-detected if omitted) |
description | no | Free-text note (e.g. why this indicator is watched) |
Add indicators by hand:
| makeresults
| eval indicator="example.com", indicator_type="domain", description="Primary domain"
| append [| makeresults | eval indicator="203.0.113.50", indicator_type="ip", description="Critical server"]
| outputlookup whisper_watchlist append=true
Or bulk-load them from what Splunk already knows:
index=firewall action=blocked
| stats count by dest_ip
| where count > 100
| rename dest_ip AS indicator
| eval indicator_type="ip", description="Frequently blocked IP"
| outputlookup whisper_watchlist append=true
If the watchlist is empty and an Attack Surface Baseline input has already run, the watchlist seeds itself from whisper_dns_baseline — your own infrastructure gets enriched by default, and you can add or remove indicators afterwards.
| inputlookup whisper_watchlist
| inputlookup whisper_precomputed_enrichment | head 10
Scheduling
| Input | Recommended interval | Why |
|---|---|---|
| Threat Intel | 6 hours | Moderate — processes many indicators |
| Baseline | 24 hours | Infrequent — DNS changes slowly |
| Watchlist | 4 hours | Moderate — depends on watchlist size |
An interval below the floor in each input's Range column is rejected when you save the configuration.
Worked examples
The add-on ships no prebuilt correlation-search pack and no analytic story. What it ships is the raw material below plus four disabled example enrichment templates in savedsearches.conf — clone one and adapt it to your data model. The templates are described under Splunk ES.
Rank firewall traffic by infrastructure
Destination IPs on their own do not sort. ASN, country and a threat verdict do:
index=firewall sourcetype=pan:traffic
| whisperlookup field=dest_ip type=ip
| where whisper_threat_score > 0
| table _time dest_ip whisper_asn_name whisper_country whisper_threat_level whisper_threat_score
| sort -whisper_threat_score
Find Tor exit nodes in your traffic
index=firewall sourcetype=pan:traffic
| whisperlookup field=dest_ip type=ip
| where whisper_is_tor="true"
| table _time src_ip dest_ip whisper_asn_name whisper_country
Find traffic to bulletproof hosting
The shipped ASN lookup carries the networks worth flagging by category:
index=firewall sourcetype=pan:traffic
| whisperlookup field=dest_ip type=ip
| lookup whisper_high_risk_asns_lookup asn AS whisper_asn OUTPUT asn_category
| where isnotnull(asn_category)
| table _time dest_ip whisper_asn whisper_asn_name asn_category
Corroborate an indicator across feeds
One listing is a lead; several independent listings is a finding:
index=firewall sourcetype=pan:traffic
| whisperlookup field=dest_ip type=ip include_threat_intel=true include_feeds=true
| where whisper_threat_sources_count > 2
| table dest_ip whisper_threat_level whisper_threat_sources_count whisper_feed_names
Map and monitor your own DNS surface
Configure the Attack Surface Baseline input with your domain list, then read what it wrote. Inventory first:
sourcetype=whisper:attack_surface
| stats dc(record_value) AS unique_records values(record_value) AS records by domain record_type
| sort domain record_type
The current value of one record type on one domain:
sourcetype=whisper:attack_surface domain="example.com"
| stats latest(record_value) AS current_value by record_type
A dangling CNAME — one that still points at a decommissioned service — is the subdomain-takeover case, and the CNAME chain macro is the quickest way to see one:
| `whisper_cname_chain("cdn.yourdomain.com")`
| table cname_chain cname_target depth
whisper_cname_chain and whisper_spf_chain are the two macros that need an API key of their own. Sign in to your Whisper account, generate a key, and set it in Settings → Whisper Graph → Credentials before you run either.
Investigate one indicator
The full-investigation macro returns the addresses a domain resolves to, its BGP prefix, its ASN and country, and how many other domains sit on the same address:
| `whisper_full_investigation("suspicious-domain.com")`
From there, pivot on what the indicator shares with others — nameservers, or the address itself:
| `whisper_shared_nameservers("malicious-domain.com")`
| `whisper_cohosted_domains("malicious-domain.com")`
And on the network behind it:
| `whisper_asn_infrastructure("AS12345")`
Attribute by registration data
Domains registered from the same contact email are the cheapest attribution pivot the graph offers:
| whisperquery query="MATCH (h:HOSTNAME {name: $domain})-[:HAS_EMAIL]->(e:EMAIL)<-[:HAS_EMAIL]-(other:HOSTNAME) RETURN other.name AS related_domain, e.name AS shared_email LIMIT 25" params="domain=suspicious-domain.com"
Audit SPF across your domains
The macro follows SPF_INCLUDE up to three hops and shows the include chain behind a domain's SPF record. It performs no compliance check: it cannot observe an RFC 7208 10-lookup violation, and it should not be read as saying one did not happen.
| `whisper_spf_chain("yourdomain.com")`
The Attack Surface Baseline input writes whisper:spf_compliance for every monitored domain on every run, which is the same analysis across your whole list rather than one domain at a time. The SPF Compliance and Mail Configuration dashboards read those events — MX inventory, recent MX changes, per-domain history — without your writing any SPL.
Write your own risk events
index=firewall sourcetype=pan:traffic
| whisperlookup field=dest_ip type=ip
| where whisper_threat_score >= 50 AND whisper_is_threat="true"
| eval risk_message="Connection to high-threat IP " . dest_ip . " (ASN: " . whisper_asn_name . ", Score: " . whisper_threat_score . ")"
| collect index=risk risk_score=whisper_threat_score risk_object=dest_ip risk_object_type=system
index=risk comes from Splunk ES. Without ES, send these events to an index you own instead — the search is otherwise unchanged.
The same questions in Cypher
Every workflow above has a graph counterpart. Use SPL when you want enrichment inline with Splunk events; use Cypher when you want to pivot through the graph and follow where it goes.
- Firewall enrichment, Tor detection → SOC recipes
- Bulletproof hosting, ASN reputation → Threat-intel recipes
- BGP hijack detection → BGP recipes
- SPF posture, dangling DNS → DNS and email recipes
- External attack-surface monitoring → Pentest recon recipes
- Third-party and vendor posture → Third-party posture recipes