Data Reference
The four Whisper custom tables with their full column contracts, and the five ingestion pipelines that fill them.
On this page (8)
Data Reference Documentation
The solution writes everything to four custom tables in your Log Analytics workspace. Workbooks, analytics rules, and hunting queries read from them — and so can any KQL of your own.
What writes what
Only the five ingestion pipelines write to these tables. The ten playbooks call the Whisper API and post their result as a comment on the incident; none of them writes a row.
| Table | Written by | When it runs |
|---|---|---|
WhisperThreatIntel_CL | Whisper-EnrichmentPipeline | On incident creation, once you have wired the automation rule |
WhisperInfraContext_CL | Whisper-InfraChainPipeline | On incident creation, once you have wired the automation rule |
WhisperHistory_CL | Whisper-WhoisHistoryPipeline (domain rows) and Whisper-BgpHistoryPipeline (IP rows) | Daily, and only for the entries on your watchlists |
WhisperASNReputation_CL | Whisper-AsnReputationPoller | Hourly, and only for your monitored ASNs |
Both incident-triggered pipelines are a precondition, not an option — see Configuration.
Column contracts
Every column below comes from the shipped table schema and its writer's field mapping in solution 3.0.0. Guarantee is one of:
- guaranteed — present on every row the writer produces.
- conditional — present only when the graph had that data for the indicator.
- declared, never emitted — the column exists in the table and nothing in the solution writes it.
A row appears at all only when the pipeline reaches its ingestion step; a failed API call is logged and the row is skipped, so a missing indicator is not a clean indicator.
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. Full contract: Coverage — what we looked at.
WhisperThreatIntel_CL
Written by Whisper-EnrichmentPipeline from CALL whisper.explain().
| Column | Type | Guarantee | When absent | What a rule must do |
|---|---|---|---|---|
indicator | string | guaranteed | never | Join on this, not on the entity name — it is the raw entity value from the incident |
indicatorType | string | guaranteed | never | Do not filter on it. Status: known issue in 3.0.0 — the pipeline derives it as "contains a dot ⇒ domain", so every IPv4 address is written as domain. Filter on the shape of indicator instead |
threatScore | real | conditional | explain() returned no row for the indicator | Treat null as not covered, never as zero |
threatLevel | string | conditional | as above | Same. The level vocabulary is INFO, LOW, MEDIUM, HIGH, CRITICAL |
isThreat | bool | conditional | as above | Use == true, not != false — null is not false here |
isC2, isMalware, isPhishing, isTor, isAnonymizer, isSpam, isBruteforce, isScanner | bool | conditional | as above | One flag being null does not mean the others are complete; test each flag you read |
threatSources | int | conditional | as above | Null and 0 both mean "no feed lists it", which is not the same as "clean" |
feedNames | string | conditional | no feed lists the indicator | Comma-joined, empty string when the list is empty. split() before matching |
explanation | string | conditional | explain() produced none | Display only. Never parse it |
factors | string | conditional | as above | A JSON array stored as a string. parse_json() before indexing into it |
lastSeen | datetime | guaranteed | never | This is the write time, not the last time a feed saw the indicator — the pipeline sets it to utcNow(). Do not use it to age a listing |
TimeGenerated | datetime | guaranteed | never | The column every ago() window should use |
WhisperInfraContext_CL
Written by Whisper-InfraChainPipeline.
| Column | Type | Guarantee | When absent | What a rule must do |
|---|---|---|---|---|
indicator | string | guaranteed | never | The incident entity the chain was built from |
indicatorType | string | guaranteed | never | ip, domain or unknown. This pipeline does parse the shape, so it is safe to filter here |
ipAddresses, prefixes, asns, asnNames, cities, countries | string | conditional | the traversal found nothing at that layer | Comma-joined lists, empty string when empty. split() and mv-expand; an empty string expands to one empty row, so filter isnotempty() after expanding |
registrar, registrant, nameservers | string | conditional | no WHOIS answer, or the indicator is an IP | Written as an empty string, never null — isnotempty() is the correct test, isnotnull() is not |
domainAge | int | guaranteed | never | Do not use it. In 3.0.0 the value is always -1: nothing computes a registration age yet, and every rule and hunt that filters domainAge >= 0 returns zero rows. Treat the field as unavailable until a release notes otherwise |
cohostedCount | int | guaranteed | never | Written as 0 when the co-hosting query returns no rows, so 0 means "not measured or genuinely zero" and cannot tell them apart |
dnssecAlgorithm | string | declared, never emitted | always | Nothing in the solution writes this column. Any panel or rule reading it reports 0 % coverage — including the SPF and DNSSEC Coverage panel of the External Attack Surface workbook |
spfIncludes | string | declared, never emitted | always | As above |
bgpStatus | string | guaranteed | never | The pipeline writes the constant "active" for every row. It carries no signal; do not branch on it |
TimeGenerated | datetime | guaranteed | never | The column every ago() window should use |
WhisperHistory_CL
One table, two shapes. Whisper-WhoisHistoryPipeline writes the WHOIS columns for domains; Whisper-BgpHistoryPipeline writes the BGP columns for IPs. Neither writes the other's columns, so every row has one half of this table empty.
| Column | Type | Guarantee | When absent | What a rule must do |
|---|---|---|---|---|
indicator | string | guaranteed | never | The watchlist entry the snapshot belongs to |
indicatorType | string | guaranteed | never | Literal domain on WHOIS rows, literal ip on BGP rows. Filter on it first — it is what separates the two shapes |
snapshotDate | datetime | guaranteed | never | When the state was observed. Order by this, not by TimeGenerated |
registrar, registrant, country, nameServers | string | conditional | on every BGP row, and on a WHOIS row the registry redacted | Note the capital S in nameServers — WhisperInfraContext_CL spells the same idea nameservers |
createDate, updateDate, expiryDate | datetime | conditional | on every BGP row, and when WHOIS omits the date | An absent date is written as an empty value and lands as null in a datetime column, so guard with isnotnull() before any datetime_diff() |
bgpOrigin, bgpPrefix | string | conditional | on every WHOIS row | |
bgpVisibility | real | conditional | on every WHOIS row | |
TimeGenerated | datetime | guaranteed | never | Ingestion time, not observation time |
Both writers are scheduled and read a watchlist. An empty watchlist means an empty table, and an empty table is indistinguishable from an indicator with no history.
WhisperASNReputation_CL
Written by Whisper-AsnReputationPoller, hourly, for the ASNs named in monitoredAsns.
| Column | Type | Guarantee | When absent | What a rule must do |
|---|---|---|---|---|
asn | string | guaranteed | never | The polled ASN. Only monitored ASNs are ever present — a join against this table silently drops every ASN you have not listed |
asnName | string | conditional | the graph has no name for the ASN | |
reputationScore, reputationLevel | real, string | conditional | explain() returned no row | Null is not polled or not covered, not good |
maxThreatScore, avgThreatScore | real | conditional | as above | An inner join on these silently drops unpolled ASNs; use a left join if the absence matters |
hasThreateningPrefixes | bool | conditional | as above | |
country | string | conditional | the graph has no registration country | |
prefixCount, peerCount | int | conditional | as above | |
TimeGenerated | datetime | guaranteed | never | The column every ago() window should use |
Watch for a table that stops receiving rows
A union over the four tables tells you what arrived. It cannot tell you about a table that has never received a row, because a wildcard union matches nothing for a table with no data — and a table that is silently missing reads exactly like an indicator that is genuinely clean.
Name the four tables and join them back with rightouter, so an absent table appears as a row rather than as nothing:
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, long(0)), Latest
| where Rows == 0 or Latest < ago(24h)
The rightouter is the whole point. An inner join drops a table that has never been written, which is the same mistake as reading no-data as clean, one layer down. The last line keeps only what is worth acting on — a table with no rows at all, and a table whose newest row is over a day old — so this query returning nothing is the answer you want.
Ingestion pipelines
Five Logic Apps feed the tables. Deployed name is what you will see in the Azure portal; repo file is the template it is deployed from, which is the name to quote in a support request.
| Deployed name | Repo file | Trigger | Purpose |
|---|---|---|---|
Whisper-EnrichmentPipeline | WhisperEnrichmentPipeline.json | Incident (wire via automation rule) | explain() enrichment of incident entities into WhisperThreatIntel_CL |
Whisper-InfraChainPipeline | WhisperInfraChainPipeline.json | Incident (wire via automation rule) | Infrastructure-chain context into WhisperInfraContext_CL |
Whisper-WhoisHistoryPipeline | WhisperWhoisHistoryPipeline.json | Daily schedule | WHOIS snapshots for your domain watchlist into WhisperHistory_CL |
Whisper-BgpHistoryPipeline | WhisperBgpHistoryPipeline.json | Daily schedule | BGP routing history for your IP watchlist into WhisperHistory_CL |
Whisper-AsnReputationPoller | WhisperAsnReputationPoller.json | Hourly schedule | Reputation refresh for your monitored ASNs into WhisperASNReputation_CL |
Four of the five take their deployed name from a template parameter, so a deployment that overrode it will show something else; Whisper-InfraChainPipeline is fixed in the template and is always that string.
The daily pipelines ship with empty watchlists and collect nothing until you set them — see Configuration.