# Data Reference

> Column contracts for the Whisper Sentinel tables WhisperThreatIntel, WhisperInfraContext, WhisperHistory and WhisperASNReputation, and their pipelines.

*Source: https://www.whisper.security/docs/integrations/sentinel/data-reference*

---
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](/docs/integrations/sentinel/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 `coverage` before `band`.** Only `known-clean` licenses the word "clean"; `no-data` means
> *unknown*, which is a different thing again; `malicious-evidenced` and `ambiguous` mean there is
> evidence, whatever the band says.
> Full contract: [Coverage — what we looked at](/docs/whisper-graph/procedures/coverage).

### `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:

```kusto
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](/docs/integrations/sentinel/configuration).
