Platform-agnostic Kusto Query Language (KQL) patterns, optimisation rules, anti-patterns, and correlation techniques shared by Microsoft Sentinel and Microsoft Defender Advanced Hunting. Covers operator hierarchy, filter ordering, joins, summarise patterns, commenting discipline, false-positive engineering, IOC templates, and bug-class anti-patterns. Use when authoring or reviewing any KQL — pair with microsoft-sentinel or microsoft-defender-endpoint for table schemas, ingestion semantics, and platform-native rule constraints.
설치
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
Platform-agnostic Kusto Query Language (KQL) patterns, optimisation rules, anti-patterns, and correlation techniques shared by Microsoft Sentinel and Microsoft Defender Advanced Hunting. Covers operator hierarchy, filter ordering, joins, summarise patterns, commenting discipline, false-positive engineering, IOC templates, and bug-class anti-patterns. Use when authoring or reviewing any KQL — pair with microsoft-sentinel or microsoft-defender-endpoint for table schemas, ingestion semantics, and platform-native rule constraints.
Kusto Query Language — platform-agnostic
This skill encodes the language-level discipline shared across Microsoft Sentinel (Log Analytics) and Microsoft Defender Advanced Hunting (M365 Defender). For platform-specific table schemas, time-field names, NRT/scheduled-rule constraints, retention and execution surfaces, always pair with microsoft-sentinel or microsoft-defender-endpoint.
Note on time-field naming: Sentinel tables use TimeGenerated. Defender device/email tables use Timestamp. The optimisation principles in this skill are identical — always filter on the datetime index first.
1. Operator hierarchy and string operations
1.1 Filter cascade — most efficient first
Priority
Filter type
Mechanism
Example
1
Time field
Datetime index → shard elimination
TimeGenerated > ago(7d) / Timestamp > ago(7d)
2
has_cs / has
Term-level inverted index lookup
ProcessCommandLine has "mimikatz"
3
== / in / in~
Exact match against indexed values
ActionType == "ProcessCreated"
4
Numeric / boolean
Column scan, fast
RemotePort == 443
5
contains family
Full column scan
RemoteUrl contains "xploi"
6
matches regex
Full scan + regex engine
... matches regex @"..."
// GOOD: time first, then narrow enums, then term lookup, regex last
SourceTable
| where TimeGenerated > ago(7d)
| where ActionType == "ProcessCreated"
| where FileName has "powershell"
| where ProcessCommandLine has "-enc"
| where ProcessCommandLine matches regex @"(?i)-e(nc|ncodedcommand)\s+[A-Za-z0-9+/]"
1.2 String operator selection
Operator
Indexed
Speed
Use when
has_cs
yes (case-sensitive)
fastest
Token, casing known
has
yes
fast
Token, casing variable
has_any / has_all
yes
fast
Multi-token membership / conjunction
== / in / in~
yes
fast
Exact / set membership
startswith_cs / startswith
partial
medium
Known prefix
contains_cs / contains
no
slow
Substring within tokens
matches regex
no
slowest
True pattern matching
Decision tree:
Complete token? → has / has_cs.
One of several tokens? → has_any.
Exact value? → == / in.
Substring within a token? → contains (and document why has is insufficient).
Pattern? → matches regex, last resort.
1.3 Case-sensitive when possible
Case-sensitive operators skip Unicode normalisation. Prefer ==, in, has_cs, contains_cs, startswith_cs when the data has consistent casing. Use case-insensitive variants for fields with known casing drift (Windows file names, UPNs, UNC paths).
1.4 Short-term limitation
Terms ≤ 3 characters are not held in the term index. has "cmd" triggers a full column scan. Use FileName in~ ("cmd.exe") or combine with longer indexed terms.
1.5 Negation traps
Single-value operators support !. Multi-value operators do not.
Positive
Valid negation
has, contains, startswith, in, in~
!has, !contains, etc. ✅
has_any, has_all, contains_any
❌ — wrap with not(... has_any (...))
matches regex
not(... matches regex ...)
// BAD: !has_any is invalid syntax
| where Column !has_any (value_list)
// GOOD
| where not(Column has_any (value_list))
and binds tighter than or (same as most languages). When a where clause mixes both, all and-groups must be wrapped in explicit parentheses even when implicit precedence happens to produce the correct result. Future edits will silently break otherwise.
// BAD
| where FileName has_any (tools)
or ProcessIntegrityLevel == "System"
and FileName !in~ ("svchost.exe", "services.exe")
// GOOD
| where FileName has_any (tools)
or (ProcessIntegrityLevel == "System"
and FileName !in~ ("svchost.exe", "services.exe"))
2. Time, joins, aggregations
2.1 Time bounds
Always first; always justified in the rationale. Production scheduled rules manage their own lookback — see platform skills for when to omitTimeGenerated/Timestamp filters (NRT pipelines, MDE custom detections).
2.2 Joins
Smaller, more-filtered side on the left. Joins look up each left row in the right table.
Time-bound both sides. Reduces inner-table scan even when seemingly redundant.
Pick the right kind: innerunique (default) deduplicates left rows; use inner when all matches are needed; leftouter for enrichment; leftanti for exclusion-based hunting / baseline deviation; leftsemi for existence checks.
Hints: hint.shufflekey = <key> for high-cardinality keys; hint.strategy = broadcast when left side is small (< 100k) and right is very large.
TableA
| where TimeGenerated > ago(7d)
| where /* selective filters */
| project SmallProjection
| join kind=inner (
TableB
| where TimeGenerated > ago(7d)
| where /* selective filters */
) on JoinKey
2.3 Temporal-window joins
Multi-table correlation must validate temporal causality. Process IDs are recycled on Windows; without a time window a January process can match a February network event sharing the same DeviceId+ProcessId.
// Pattern: B happens within N minutes of A
let event_a =
TableA
| where TimeGenerated > ago(7d)
| project TimeA = TimeGenerated, JoinKey, ContextA;
let event_b =
TableB
| where TimeGenerated > ago(7d)
| project TimeB = TimeGenerated, JoinKey, ContextB;
event_a
| join kind=inner event_b on JoinKey
| where (TimeB - TimeA) between (0min .. 30min)
Where available, join on stable identifiers (ProcessUniqueId / InitiatingProcessUniqueId) rather than recycled PIDs.
2.4 Aggregations
project, not summarize by when the column is already unique per row.
Aggregate after selective filters, never before — summarize materialises every group before the post-aggregation where.
hint.shufflekey when grouping by columns with millions of distinct values.
Reduce columns before join/summarize to lower memory pressure.
2.5 Filter raw columns, not calculated ones
// BAD: extend creates a column for every row, then scans it
| extend CmdLower = tolower(ProcessCommandLine)
| where CmdLower contains "invoke-mimikatz"
// GOOD
| where ProcessCommandLine has "Invoke-Mimikatz"
has, contains, in~ are already case-insensitive — tolower() is unnecessary and slow.
Detection rules without inline rationale fail review.
4. let, IOCs, reusable references
// Tuning thresholds with guidance
let threshold_failures = 50; // Default 50 — lower for privileged accounts, raise for service accounts
let lookback = ago(14d);
// IOC arrays with provenance comments
let malicious_ips = dynamic(["203.0.113.10", "198.51.100.20"]); // Source: <reference>
let malicious_domains = dynamic(["evil.example", "c2.example.net"]);
Inline IOCs with let + dynamic([...]); avoid externaldata() for production rules unless your tenant explicitly supports it.
Inline reference tables via datatable(...) for known-good lists, Tier 0 groups, exclusion lookups.
materialize() when the same intermediate set feeds multiple downstream joins (e.g. TI feed dedup before joining against multiple log tables).
let active_ti = materialize(
TIIndicators
| where TimeGenerated > ago(14d)
| where ExpirationDateTime > now() and Active == true
| summarize arg_max(TimeGenerated, *) by IndicatorId
| extend TI_IP = coalesce(NetworkIP, NetworkSourceIP, NetworkDestinationIP)
| where isnotempty(TI_IP)
| project TI_IP, ThreatType, ConfidenceScore
);
// Reuse active_ti against multiple log tables …
5. Advanced operators worth knowing
Operator
Purpose
Use case
materialize()
Cache intermediate result
Reused subqueries, TI dedup
mv-apply
Per-element array operations
Beaconing intervals, array conditions
set_difference()
Historical baseline comparison
New autoruns, new scheduled tasks
datatable()
Inline reference table
Known-good lists, Tier 0 groups
prev() / next()
Window functions
Impossible travel, sequential analysis
row_window_session()
Dynamic session windows
Brute-force clustering (better than fixed bin())
series_decompose_anomalies()
ML anomaly detection
Volume spikes; pair with make-series
series_outliers()
Statistical outlier removal
Cleaning baselines
arg_max() / arg_min()
Row with extreme value per group
Latest event per entity, indicator dedup
top-nested N of X by Y
Nested rarity / prevalence
Rare processes per device, rare apps per user
coalesce()
First non-null across columns
TI IP normalisation
parse kind=regex flags=iU
Case-insensitive ungreedy regex
Command-line / URL extraction
ipv4_is_in_range()
CIDR matching
Subnet filtering without strings
make-series + anomaly detection
SourceTable
| where TimeGenerated > ago(30d)
| make-series Count = count() on TimeGenerated from ago(30d) to now() step 1h by Entity
| extend (Anomalies, Score, Expected) = series_decompose_anomalies(Count, 1.5)
| mv-apply Anomalies on (where Anomalies == 1 | take 1)
| project Entity, Count, Expected, Score
When KQL is embedded in a record that carries a rationale field (hunt query objects, OpenTide MDR description/response.procedure), the rationale must answer four questions:
Why these tables? What captures the observable; what was rejected.
Why these filters? Map every non-trivial filter to source intelligence or behavioural reasoning.
What the query does NOT cover. Variants, evasion, scenarios excluded by scope.
How the result connects to the hypothesis. What a true positive would prove.
Score
Precision (FP volume in clean env)
Recall risk
HIGH
< 10 results; filters trace to specific intel
Filters are narrow; variants may evade
MEDIUM
10–100; mix of specific + behavioural
Covers known patterns; novel variants may evade
LOW
> 100; broad behavioural pattern
Broad detection; hard to evade
9. Quality checklist
Time bound present as first filter (or omitted intentionally for NRT, with rationale).
No full-table scans (at least one selective predicate beyond time).
has/has_any over contains wherever applicable.
Output reduced via project or summarize — never return all columns.
Inline comments per non-trivial filter, exclusion, join, summarise.