What Every Connector Guarantees
The field contract shared by all four connectors: which columns are guaranteed, which are conditional, which are absent on failure, what a rule must do when one is missing, and the verdict semantics every one of them writes.
What Every Connector Guarantees Documentation
Splunk writes whisper_threat_level onto your events, Microsoft Sentinel writes threatLevel into WhisperThreatIntel_CL, OpenCTI writes a note on the observable, the Wazuh connector writes data.whisper.level into a new alert beside the original. Four spellings, one fact: all four ask the same graph — 7.5B nodes and 39.4B edges, 76 threat feeds, measured 2026-08-09. What follows is the contract those spellings share, and what a detection has to do when one of them does not arrive.
The field contract
Guarantee takes the three values Data Reference uses for Sentinel's own columns: guaranteed, on every record that connector writes; conditional, only when the graph held that fact; absent, the connector does not carry it. Absent is a contract rather than an omission — a rule that needs the fact needs a different connector, or a query of your own.
| The fact | Splunk | Sentinel | OpenCTI | Wazuh data.whisper.* | Type | Guarantee | When it is absent, and what a rule must do |
|---|---|---|---|---|---|---|---|
| The indicator | indicator | indicator | the seed observable | ioc | string | guaranteed | Never. Join on this value, not on the entity name your console displays |
| Its type | indicator_type | indicatorType | the SCO type | type | string | guaranteed | Never absent, but wrong on WhisperThreatIntel_CL: that pipeline reads "contains a dot" as domain, so every IPv4 address lands as one. Filter on the indicator's shape there. WhisperInfraContext_CL parses it and is safe |
| The threat score | whisper_threat_score | threatScore | the score in the threat note | risk_score | float, unbounded | conditional | Absent when the scoring call returned nothing. Missing is not zero — the null is an absence of evidence, the zero is evidence, and a rule that coalesces them has stopped measuring |
| The threat level | whisper_threat_level | threatLevel | the level in the threat note | level | enum, below | conditional | Absent as above; on OpenCTI there is no threat note at all when nothing lists the seed. Neither absence nor NONE is a clean verdict — read the coverage contract below |
| Per-feed evidence | whisper_feed_names, whisper_threat_sources_count | feedNames, threatSources | each listing feed in the threat note | threat_feed.feeds[], .categories[], .sources_count | list, int | conditional | Absent when no feed lists the indicator — which is also what an indicator nobody has ever observed looks like. This field cannot tell the two apart |
The is* threat flags — C2, malware, phishing, Tor, anonymizer, spam, brute force, scanner | whisper_is_c2 … | isC2 … | the flags line of the threat note | threat_feed.flags[] | bool, or a list of set flags | conditional | Test each flag you read — one arriving does not mean the rest are complete — and test == true, never != false. Wazuh writes positives only, so a flag missing from its list means not attested, not false |
| Network context — ASN, prefix, country | whisper_asn, whisper_prefix, whisper_country | asns, prefixes, countries on WhisperInfraContext_CL | an autonomous-system SCO and a location SDO | asn.number, prefix, geo.country | string, or a list | conditional | Absent when the traversal found nothing at that layer; geolocation on an anycast address reports the operator, not the edge that answered. STIX has no prefix object, so on OpenCTI the prefix is prose in a note |
| When a feed saw it | whisper_threat_first_seen, whisper_threat_last_seen | — | per feed in the threat note | threat_feed.first_seen, .last_seen | date | conditional, and absent on Sentinel | Sentinel's lastSeen is the pipeline's own write time, utcNow(). It answers every query you point at it, and the answer is about our clock, not the threat. Never age a listing on it |
| A derived verdict | whisper_risk_level, whisper_risk_score | — | — | verdict: known_good · known_bad · suspicious · unknown | string | conditional, and absent on Sentinel and OpenCTI | Sentinel writes isThreat, a flag rather than a judgement; OpenCTI withholds the verdict on purpose, creating no STIX indicator and setting no platform score. Where one exists it is the connector's arithmetic — Splunk's risk_level is not Whisper's threat_level |
| Whether the backend answered | whisper_threat_available | — | the work-item status message | available | bool | conditional, and absent on Sentinel | The field separating we looked and found nothing from we could not look. On Sentinel a failed call is logged and the row skipped, so nothing records the attempt — which is why the watchdog below is not optional |
The Splunk column names the whisper_-prefixed original; its CIM alias sits beside it on the same event — CIM Mapping.
Absence has four shapes, and only one is null
The same missing fact arrives differently in each tool, so the test that finds it differs too. Splunk enriches through OPTIONAL MATCH and leaves the field off the event, so isnotnull() is the test. Sentinel writes the string columns of WhisperInfraContext_CL as an empty string, never null — isnotempty() is correct there and isnotnull() passes every row. OpenCTI creates no note at all. Wazuh strips nullable fields before sending, because analysisd would index a JSON null as the literal string "null". A rule ported between two of these without changing its emptiness test has stopped filtering.
One threat level, and it has six values
NONE · INFO · LOW · MEDIUM · HIGH · CRITICAL
This is the enum explain() returns and every connector carries through. Where a connector page names a shorter ladder, this page governs.
INFO is the value that gets dropped, and dropping it always fails the same way. It is what a clean, well-known address comes back as — 8.8.8.8 returns it — so a rule whose lowest band is LOW discards exactly the observables its author assumed were covered, and the discarded rows look identical to rows that were never enriched.
Two things the enum does not tell you. Splunk's add-on derives the level from the score when the API returns none, so a level there is not always one the graph produced. And NONE is a score band, not a finding: reserved fixture hostnames that do not exist have come back whisper_threat_level = NONE, a reassuring verdict on infrastructure that was never there.
A level is not a verdict
Which is why the block below, transcluded from the one place it is written, governs every field above.
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 licenses the word "clean". Every other value is not-clean — and no-data and
deadline-hit mean unknown, which is a different thing again.
whisper.assess and whisper.assessUrl return coverage. whisper.explain does not.
coverage | What it means | What to do |
|---|---|---|
known-clean | We hold data at this granularity and nothing malicious is in it. | Treat as clean. This is the only value that licenses closing a ticket on "clean." |
malicious-evidenced | Some positive evidence of malice exists. It may be a single feed at weight 0.5. It does not mean the band is high. | Read evidence[] for feed-source-count, then run explain() for the per-feed provenance, weights and timestamps. A count of 1 on a low-weight aggregate list is a lead, not a finding. |
ambiguous | The evidence points both ways — for example an anonymising-egress signal alongside generic abuse listings. | Escalate to a human. Do not automate a decision on this value. |
no-data | We have never observed this host. | Unknown. Never benign. Ask a different question — the container, the operator, the age — and escalate with "we have no observation of this host", never with "it came back clean." |
Every one of these arrives as a populated row. no-data is a row that says no-data; it is
never an empty result set. If a query returns zero rows, the first hypothesis is that the query is
wrong, not that the host is clean.
Which procedure carries coverage — measured on production whisper-ng/4.220.5, 2026-08-09:
| 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-only is a whisper.walk value describing atlas adjacency. It is not a
whisper.assess value, and a branch keyed on it in an assess result is unreachable — see
the full contract.
A table that stops filling has to be audible
Every connector writes into tables you then build detections on, and the failure that costs the most is not a wrong row — it is no row. A pipeline that quietly stops writing looks exactly like a quiet week. Nothing alerts, every dashboard is green, and the rule reading that table returns zero on every run without ever saying why.
So watch the tables themselves. On Microsoft Sentinel, where the four Whisper tables are the ones to watch:
let Expected = datatable(TableName: string)
[
"WhisperThreatIntel_CL", "WhisperInfraContext_CL",
"WhisperHistory_CL", "WhisperASNReputation_CL"
];
union isfuzzy=true withsource = TableName
WhisperThreatIntel_CL, WhisperInfraContext_CL,
WhisperHistory_CL, WhisperASNReputation_CL
| summarize Rows = count(), Latest = max(TimeGenerated) by TableName
| join kind=rightouter Expected on TableName
| project Table = TableName1, Rows = coalesce(Rows, 0L), Latest
| where Rows == 0 or Latest < ago(24h)
The rightouter join is the point of the query, not a detail of it. An inner join can only report tables that already have rows, so a table that has never received one drops out of the result entirely and the query reads as though everything is fine. The outer join keeps the four expected names on the right-hand side and lets a table that has never been written surface as a zero rather than as an absence. Treating a missing table as "nothing to report" is the same mistake as treating no data as a clean verdict, one layer down — in both cases the system answers a question it has no evidence for, and the answer looks reassuring.
Run it as a scheduled rule rather than by hand, and let it page you when a count goes to zero or a table goes quiet for a day. A watchdog you have to remember to run is a watchdog that reports the outage after you have already found it. The same pattern applies to the other three connectors against whatever store they write into; what does not change is the join.
The Sentinel instance of this query, alongside the per-detection preconditions it protects, is on Workbooks & Detections.
When a field disagrees with this page
One destination, whichever connector it is: Support. It lists what to send so the first reply is an answer — the indicator, the record as it landed, and the X-Request-Id and X-Served-By headers, which are usually the fastest explanation for a field that was there yesterday.