Automation
Microsoft Sentinel: validate a watchlist before it drives automated response
A production runbook for proving watchlist freshness, schema, SearchKey, deletions and KQL behavior before allowing it to influence automated response.
A Microsoft Sentinel rule suddenly stops creating incidents for some privileged accounts. Elsewhere, a playbook blocks an IP address that should no longer be on the watchlist. The KQL query runs, the watchlist exists and automation is green. The defect sits in the reference data driving the decision.
The running case is a privileged_assets_prod watchlist populated from an inventory export. An analytics rule uses it to enrich signals, then an automation rule hands incidents to a playbook. The runbook must decide whether the new version can drive production, remain in observation or return to the previous snapshot without silently widening detections or acting on a stale target.
Freeze the decision contract
A watchlist is more than a CSV file. It is a detection dependency with an owner, source, join key, business meaning and failure behavior. Describe that contract before opening the portal.
watchlist:
alias: privileged_assets_prod
source: cmdb-export
owner: secops-platform
search_key: AssetId
source_snapshot: cmdb-20261001T051500Z
generated_at_utc: 2026-10-01T05:15:00Z
expected_rows: 1842
decision:
used_by:
- analytics-rule-privileged-signin
- automation-rule-high-risk-identity
match_means: asset_is_in_scope
no_match_means: do_not_auto-remediate
duplicate_key: reject_candidate
expired_row: ignore_and_alert
rollback:
previous_alias: privileged_assets_20260930
automation_mode: observe_only The meaning of no match is critical. An allowlist, a sensitive-target inventory and a malicious-indicator list must not share the same fallback. If the contract does not say whether absence allows, blocks or suspends a decision, the watchlist must not trigger an action.
Separate platform freshness from business freshness
First prove the alias actually called. _GetWatchlistAlias inventories available aliases; _GetWatchlist('alias') returns the rows KQL consumes. Check both planes: watchlist management status and queryable content can diverge during ingestion or a service incident.
Do not use TimeGenerated as source-freshness evidence. Microsoft Sentinel periodically refreshes watchlists in the workspace and updates this field. A row can therefore have a recent TimeGenerated while coming from an old business export. Add explicit source fields such as SourceUpdatedAt, SnapshotId and ExpiresAt.
let ExpectedAlias = "privileged_assets_prod";
let MaxSourceAge = 6h;
let Rows =
_GetWatchlist(ExpectedAlias)
| extend SourceUpdatedAt = todatetime(SourceUpdatedAt),
ExpiresAt = todatetime(ExpiresAt),
Key = tostring(SearchKey);
Rows
| summarize
Rows=count(),
DistinctKeys=dcount(Key),
MissingKeys=countif(isempty(Key)),
OldestSource=min(SourceUpdatedAt),
NewestSource=max(SourceUpdatedAt),
ExpiredRows=countif(ExpiresAt < now()),
FutureRows=countif(SourceUpdatedAt > now() + 5m),
Snapshots=dcount(tostring(SnapshotId))
| extend SourceIsFresh = NewestSource > ago(MaxSourceAge) A healthy result is not merely Rows > 0. It requires the expected snapshot, no blank keys, age compatible with the contract and one logical data generation. A future source clock, several SnapshotId values or volume far outside the baseline must stop automation.
Treat deletions as an explicit operation
A bulk update appends rows and then de-duplicates identical rows. Removing an entry from the new CSV does not necessarily remove the existing watchlist item. This is a major trap for a denylist, temporary allowlist or remediation-target inventory: a value removed at the source can keep influencing KQL.
Build the candidate as a controlled snapshot. Compare added, modified and removed keys with the active version. For many deletions, prefer a new versioned watchlist, validate it, then change the alias referenced by the rule. Keep the old version intact through the rollback window.
let Current =
_GetWatchlist('privileged_assets_20260930')
| project Key=tostring(SearchKey), CurrentPresent=true,
CurrentOwner=tostring(Owner), CurrentPolicy=tostring(ActionPolicy);
let Candidate =
_GetWatchlist('privileged_assets_20261001')
| project Key=tostring(SearchKey), CandidatePresent=true,
CandidateOwner=tostring(Owner), CandidatePolicy=tostring(ActionPolicy);
Current
| join kind=fullouter Candidate on Key
| extend Change = case(
isnull(CurrentPresent), "added",
isnull(CandidatePresent), "removed",
CurrentOwner != CandidateOwner or CurrentPolicy != CandidatePolicy, "modified",
"unchanged")
| summarize Rows=count(), Sample=make_set(Key, 20) by Change
| order by Change asc The source owner, not only the Sentinel team, must review the comparison. A removal can be expected because an asset was decommissioned. It can also expose a partial export, a broken filter or lost permission on the CMDB.
Qualify the key before the rule
The SearchKey should be the column most frequently used for joins. That does not guarantee uniqueness or normalization. Spaces, case differences, UPN formats, IPv6 addresses or noncanonical Azure resource IDs can produce false no match results.
Normalize both sides of the join and measure its outcome. Do not hide duplicates with distinct before understanding their source.
let Assets =
_GetWatchlist('privileged_assets_20261001')
| extend JoinKey=tolower(trim(' ', tostring(SearchKey)))
| summarize WatchlistRows=count(), Owners=make_set(tostring(Owner), 5),
Policies=make_set(tostring(ActionPolicy), 5) by JoinKey;
SigninLogs
| where TimeGenerated > ago(2h)
| extend JoinKey=tolower(trim(' ', tostring(UserPrincipalName)))
| lookup kind=leftouter Assets on JoinKey
| extend MatchState = case(
isempty(JoinKey), "event-key-missing",
isnull(WatchlistRows), "not-matched",
WatchlistRows > 1, "ambiguous",
"matched")
| summarize Events=count(), SampleUsers=make_set(UserPrincipalName, 10) by MatchState Review join semantics as well. An enrichment lookup preserves unrecognized events, while an inner join removes them. A rule detecting unknown accounts and a rule targeting only privileged accounts require opposite behavior. Tests must make that choice visible.
Test the rule with bounded cases
Validation must cover both the watchlist and its consumers. Replay an approved event window against the candidate without creating incidents or calling the playbook. For every case, retain the source key, matched row, decision branch and action that would have been proposed.
cases:
- name: current_privileged_account
key: admin-api@contoso.example
expected: match_and_enrich
- name: removed_account
key: legacy-admin@contoso.example
expected: no_match_and_no_action
- name: normalized_case
key: ADMIN-API@CONTOSO.EXAMPLE
expected: one_canonical_match
- name: duplicate_key
key: shared-ops@contoso.example
expected: reject_candidate
- name: expired_exception
key: breakglass-test@contoso.example
expected: ignore_and_alert
promotion_requires:
- expected snapshot id
- row delta reviewed
- zero blank or duplicate keys
- removed rows absent from candidate
- expected join outcome for every case
- automation held in observe-only A global alert-count comparison is insufficient. The same volume can conceal different targets. Compare matched identifier sets, nonmatches and proposed decisions between old and new versions.
Switch the reference without rewriting the evidence
Canary the new watchlist against a disabled copy of the analytics rule or in a shadow evaluation query. When the cases pass, change only the watchlist reference in the rule, keep the playbook in observe-only, then verify at least one complete execution cycle.
During the window, correlate rule version, alias, SnapshotId, incident, Logic Apps run and proposed target. A green run proves the workflow completed. It does not prove that the list was complete or the target correct.
Then enable action for a bounded scope with human approval. Stop if volume drifts, a key becomes ambiguous, the snapshot changes without an approved change, or the playbook receives a target absent from the shadow-evaluation result.
Decide and roll back
Promote the candidate when alias and snapshot are identified, the delta is explained, removals are real, the key is unique, the join produces expected branches and the canary ties every decision to its reference row.
Stay in observation when the data is useful but freshness or ownership remains unproven. Reject cutover when the export is partial, an update retained deleted rows, or no match leads to a permissive or destructive action.
To roll back, disable the automated action first, point the rule back to the previous alias, run the same control cases and review incidents created during the window. An executed action must be reversed from its operation ID. Restoring the old watchlist does not restore an account, host or network rule.
Conclusion
A Sentinel watchlist is decision code expressed as data. Its presence in the portal and a recent TimeGenerated prove neither origin, completeness nor removal of stale entries.
Production use becomes defensible when snapshot, deletions, SearchKey, normalization and join behavior are tested together. The final decision is explicit: promote the reference, maintain observation or return to the previous snapshot before stale data becomes a production action.