.github/skills/kql-query-authoring/SKILL.md
Use this skill when asked to write, create, or help with KQL (Kusto Query Language) queries for Microsoft Sentinel, Defender XDR, or Azure Data Explorer. Triggers on keywords like "write KQL", "create KQL query", "help with KQL", "query [table]", "KQL for [scenario]", or when a user requests queries for specific data analysis scenarios. This skill uses schema validation, Microsoft Learn documentation, and community examples to generate production-ready KQL queries.
npx skillsauth add scstelz/security-investigator kql-query-authoringInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Generate validated, production-ready KQL queries by combining schema validation (331+ indexed tables), Microsoft Learn documentation, community examples, and performance best practices.
Required MCP Servers:
KQL Search MCP Server — Schema validation, query examples, table discovery
npm install -g kql-search-mcp (npm)Microsoft Docs MCP Server — Official Microsoft Learn documentation and code samples
Verification: Tools should be available as mcp_kql-search_* and mcp_microsoft-lea_*.
search_favorite_repos Bug (v1.0.5)❌ Broken — ERROR_TYPE_QUERY_PARSING_FATAL. Use mcp_kql-search_search_github_examples_fallback instead.
Validate table schema FIRST — mcp_kql-search_get_table_schema to verify table exists, column names, and data types.
Check platform schema — Sentinel uses TimeGenerated; Defender XDR uses Timestamp. Microsoft Learn examples default to XDR syntax — always convert before testing on Sentinel.
Check local query library FIRST — Use the discovery manifest (.github/manifests/discovery-manifest.yaml) for domain/MITRE lookups and grep_search for table-name/keyword lookups. See the KQL Pre-Flight Checklist in copilot-instructions.md for the full priority order.
Use multiple sources — Schema (authoritative column names) + Microsoft Learn (official patterns) + community queries (real-world examples).
Test using the correct execution tool — Follow the Tool Selection Rule in copilot-instructions.md:
Test queries before presenting to user — Run with | take 5 via live execution. Use mcp_kql-search_validate_kql_query as fallback if live testing unavailable.
Provide context — Explain what the query does, expected results, and any limitations.
Read the complete workflow below before starting.
📋 Inherited rules: This skill inherits the KQL Pre-Flight Checklist, Tool Selection Rule (Data Lake vs Advanced Hunting), and Known Table Pitfalls from
copilot-instructions.md. Those rules are authoritative — do not contradict them here.
Extract key information:
EntraIdSignInEvents, EmailEvents, SecurityAlert)Custom Detection Intent Detection:
If the user mentions "custom detection", "detection rule", "deploy as detection", "CD rule", "author detections for", or "deploy to Defender":
.github/skills/detection-authoring/SKILL.md) — Critical Rules and CD Metadata Contract sectionsTimeGenerated, DeviceName, ReportId), no bare summarizecd-metadata blocks in the output file (see Step 8)let variables, 7d lookback) — adaptation to CD format happens at deployment time via the detection-authoring skillSearch for existing verified queries before writing from scratch. Use two complementary methods:
.github/manifests/discovery-manifest.yaml and match by domain tag (e.g., identity, endpoint, email) or MITRE technique ID (e.g., T1078, T1566). Best when you know the security domain or ATT&CK technique.grep_search (table/keyword): grep_search for the specific table name (e.g., CloudAppEvents, OfficeActivity) or operation keyword (e.g., New-InboxRule, SecretGet) scoped to queries/** and .github/skills/**. The manifest lacks table-name and keyword fields — grep fills this gap.copilot-instructions.mdWhen to use which: Domain/technique known → manifest first. Table name/operation known → grep first. Both can be used together — manifest for breadth, grep for precision.
If a suitable query is found, adapt it and skip to Step 6. These queries encode known pitfalls and schema quirks.
mcp_kql-search_get_table_schema("<table_name>")
Returns: category, description, all columns with data types, and example queries. Use this to verify column names and understand data types.
mcp_microsoft-lea_microsoft_code_sample_search(
query: "<table_name> <scenario description>",
language: "kusto"
)
Include table name + scenario in the query (e.g., "EmailEvents phishing detection").
mcp_kql-search_search_github_examples_fallback(
table_name: "<table_name>",
description: "<goal description>"
)
Also available: mcp_kql-search_search_kql_repositories to find KQL-focused repos.
Combine insights: schema for column names, Learn for patterns, community for techniques.
Standalone queries rule: When generating MULTIPLE separate queries, each must start directly with the table name — never use shared let variables across separate queries (they run independently). Use let variables only within a single complex query.
Test queries against live data before presenting to the user.
Timestamp → TimeGenerated if adapting MS Learn examples for Sentinelmcp_sentinel-data_query_lake or RunAdvancedHuntingQuery with | take 5Common errors:
| Error | Fix |
|-------|-----|
| Failed to resolve column 'Timestamp' | Use TimeGenerated (Sentinel) |
| Failed to resolve column 'TimeGenerated' | Use Timestamp (XDR AH) |
| Table not found | Verify with get_table_schema; try the other execution tool |
| expected string expression | Add tostring() after mv-expand or parse_json |
| Query timeout / too many results | Add datetime filter + take or summarize |
Fallback validation: mcp_kql-search_validate_kql_query("<query>") — syntax/schema check only, no live data.
Single query: Provide directly in chat with brief explanation and expected results.
Multiple queries (3+): Create a markdown file in queries/<subfolder>/ with the standardized metadata header. This header is mandatory — build_manifest.py parses it to index the file for discovery by threat-pulse and other skills.
File naming: queries/<subfolder>/<topic>.md — e.g., queries/email/email_threat_detection.md
Required metadata header template (first 10 lines of every query file):
# <Descriptive Title>
**Created:** YYYY-MM-DD
**Platform:** Microsoft Sentinel | Microsoft Defender XDR | Both
**Tables:** <comma-separated exact KQL table names>
**Keywords:** <comma-separated searchable terms — attack techniques, scenarios, field names>
**MITRE:** <comma-separated technique IDs, e.g., T1098.001, T1136.003, TA0008>
**Domains:** <comma-separated domain tags from the valid set below>
**Timeframe:** Last N days (configurable)
Valid domain tags: incidents, identity, spn, endpoint, email, admin, cloud, exposure
| Field | Purpose | Parsed By |
|-------|---------|-----------|
| Tables: | Exact KQL table names for grep_search discovery | build_manifest.py (full manifest) |
| Keywords: | Searchable terms for attack scenarios, operations, field names | build_manifest.py (full manifest) |
| MITRE: | ATT&CK technique/tactic IDs for cross-referencing | build_manifest.py (slim + full) |
| Domains: | Domain tags for threat-pulse cross-referencing | build_manifest.py (slim + full) — missing = validation error |
After creating a new query file: Run python .github/manifests/build_manifest.py to regenerate the discovery manifest, then run python scripts/generate_tocs.py to auto-generate the Quick Reference TOC. The validator will flag any missing required fields.
Subfolder selection: Place files in the subfolder matching the primary data source: identity/, endpoint/, email/, network/, cloud/.
Include per-query documentation with Purpose, Thresholds, Expected Results, and Tuning guidance.
Heading format for TOC compatibility: The generate_tocs.py script auto-generates a Quick Reference TOC by scanning ### and ## Query headings that have a KQL code block within 40 lines. To ensure clean TOC output:
### Query N: <Title> or ## Query N: <Title> — the number prefix ensures proper TOC ordering### Deployment, ### Tuning, ### References). These are automatically filtered out by the TOC generator### headings for non-query content that contains a KQL code block within 40 lines — the TOC generator uses KQL proximity to detect query headings and will incorrectly include themInvestigation shortcuts (optional): Query files can include an **Investigation shortcuts:** bulleted list between the ## Quick Reference heading and the TOC table. These document recommended query combos for common investigation scenarios (e.g., "Delivered phishing drill-down: Q2.4 + Q7.6 + Q3.3"). Shortcuts are preserved by generate_tocs.py across re-runs. Don't add them to new files — they're a refinement added after real investigations reveal which query combos work best together.
When CD intent is detected (Step 1), each query MUST include a <!-- cd-metadata --> HTML comment block. The full schema is in .github/skills/detection-authoring/SKILL.md under CD Metadata Contract.
Valid cd-metadata fields (exhaustive list):
| Field | Required | Notes |
|-------|----------|-------|
| cd_ready | Always | true or false |
| schedule | If cd_ready | "0" (NRT), "1H", "3H", "12H", "24H" |
| category | If cd_ready | MITRE tactic (e.g., Persistence, CredentialAccess) |
| title | Optional | Dynamic title with {{Column}} placeholders (max 3 unique columns across title + description) |
| impactedAssets | If cd_ready | Array of type + identifier pairs |
| recommendedActions | Optional | Triage and response guidance string |
| adaptation_notes | Optional | What needs to change for CD format |
⛔ responseActions is NOT a valid cd-metadata field. It shares a name with the Graph API field that is explicitly prohibited in LLM-authored detections ("responseActions": [] is mandatory). Do not include it. Put incident response guidance in recommendedActions instead.
<!-- cd-metadata
cd_ready: true
schedule: "1H"
category: "Persistence"
title: "Suspicious Scheduled Task on {{DeviceName}}"
impactedAssets:
- type: device
identifier: DeviceName
recommendedActions: "Investigate the task XML and decode any encoded payloads."
adaptation_notes: "Remove let blocks, add mandatory columns"
-->
For queries not suitable for CD (baseline/statistical):
<!-- cd-metadata
cd_ready: false
adaptation_notes: "Statistical baseline — requires bare summarize, not CD-compatible"
-->
Summary table: Include a CD column in the Implementation Priority table: ✅ 1H / ❌.
| Tool | Purpose |
|------|---------|
| mcp_kql-search_get_table_schema | Get table columns, types, example queries (Step 3) |
| mcp_microsoft-lea_microsoft_code_sample_search | Official MS Learn KQL samples — use language: "kusto" (Step 4) |
| mcp_kql-search_search_github_examples_fallback | Community KQL examples by table name (Step 5) |
| mcp_kql-search_search_kql_repositories | Find GitHub repos with KQL collections |
| mcp_kql-search_validate_kql_query | Syntax/schema validation (fallback for Step 7) |
| mcp_kql-search_find_column | Find which tables contain a specific column |
| mcp_kql-search_generate_kql_query | Auto-generate schema-validated query from natural language |
| mcp_sentinel-data_query_lake | Execute KQL against live Sentinel (primary validation) |
| mcp_sentinel-data_search_tables | Discover tables using natural language |
| Platform | Timestamp Column | Notes |
|----------|-----------------|-------|
| Sentinel / Log Analytics | TimeGenerated | All ingested logs |
| Defender XDR (Advanced Hunting) | Timestamp | XDR-native tables only; Sentinel tables in AH still use TimeGenerated |
Other common differences: Identity/UserPrincipalName (Sentinel) vs AccountUpn/AccountName (XDR); IPAddress (Sentinel) vs RemoteIP/LocalIP (XDR). Always verify with get_table_schema.
Sign-in queries are the most common query type. Use this decision rule:
| Scenario | Table | Key Differences |
|----------|-------|-----------------|
| AH query, ≤30d | EntraIdSignInEvents (single table) | Covers both interactive + non-interactive. ErrorCode (int), AccountUpn, Country/City (direct strings), LogonType (JSON array — use has), Timestamp |
| Data Lake / >30d | SigninLogs + AADNonInteractiveUserSignInLogs (union) | ResultType (string), UserPrincipalName, parse_json(LocationDetails) needed for geo, IsInteractive (bool), TimeGenerated |
Common mistakes:
union SigninLogs, AADNonInteractiveUserSignInLogs in AH queries — unnecessary, EntraIdSignInEvents covers bothLogonType == "nonInteractiveUser" — values are JSON arrays (["nonInteractiveUser"]), use hasResultType on EntraIdSignInEvents — column is ErrorCode (int), not stringFull details: See
copilot-instructions.md→ Known Table Pitfalls →EntraIdSignInEvents (AH table preference rule)for complete column mapping and additional pitfalls.
Full table pitfalls (dynamic field parsing, immutable fields, table casing, deprecated tables) are documented in
copilot-instructions.mdunder Known Table Pitfalls. Refer there forSecurityAlert.Status,AuditLogs.InitiatedBy,SigninLogs.DeviceDetail, and 20+ other table-specific gotchas.
Reference: KQL Best Practices — Microsoft Learn
The most important optimization. Datetime predicates use efficient index-based shard elimination, skipping entire data partitions without scanning.
// ✅ Correct — datetime first, then selective string filters
SigninLogs
| where TimeGenerated > ago(7d)
| where UserPrincipalName =~ "[email protected]"
// ❌ Wrong — string filter before datetime
SigninLogs
| where UserPrincipalName =~ "[email protected]"
| where TimeGenerated > ago(7d)
has over contains for token matchinghas uses the term index for full-token lookup. contains scans every character — dramatically slower on large tables.
// ✅ Faster — term-level index lookup
| where UserPrincipalName has "admin"
// ❌ Slower — full substring scan
| where UserPrincipalName contains "admin"
Use contains only when you genuinely need substring matching (e.g., fragments inside URL paths).
Case-sensitive comparisons (==, in, has_cs) are faster than case-insensitive (=~, in~, has). Use case-insensitive only when casing is unpredictable.
// ✅ Faster — ActionType, Operation, OfficeWorkload have consistent casing
| where ActionType == "LogonFailed"
| where Operation in ("New-InboxRule", "Set-InboxRule")
| where OfficeWorkload == "Exchange"
// 🔵 Use =~ only when casing varies (e.g., user-entered UPNs)
| where UserPrincipalName =~ "[email protected]"
Common fields with consistent casing (always use == / in): ActionType, Operation, OfficeWorkload, EventID, ResultType, DeliveryAction, EmailDirection, LogonType, Severity, Status, Classification.
Pre-filter both sides of a join to reduce data volume. Move where clauses into subqueries.
// ✅ Correct — filter KB table before joining
DeviceTvmSoftwareVulnerabilities
| join kind=inner (
DeviceTvmSoftwareVulnerabilitiesKB
| where IsExploitAvailable == true
| where CvssScore >= 8.0
) on CveId
// ❌ Wrong — joins full tables, filters after
DeviceTvmSoftwareVulnerabilities
| join kind=inner DeviceTvmSoftwareVulnerabilitiesKB on CveId
| where IsExploitAvailable == true
Join sizing rules:
hint.strategy=broadcast when left is small)in instead of left semi join for single-column filteringlookup instead of join when right side is small (<50 MB)hint.shufflekey=<key> when both sides are large with high-cardinality join keymaterialize() for multi-referenced let statementsWithout materialize(), the engine may recompute the let expression each time it's referenced.
// ✅ Computed once, reused twice
let SprayFailures = materialize(
EntraIdSignInEvents
| where Timestamp > ago(7d)
| where ErrorCode in (50126, 50053, 50057)
| summarize FailedAttempts = count(), TargetUsers = dcount(AccountUpn)
by SourceIP = IPAddress
| where TargetUsers >= 5);
arg_max to only needed columnsarg_max(TimeGenerated, *) materializes every column. Specify only what you use.
// ✅ Only 5 columns materialized
SecurityAlert
| where TimeGenerated > ago(30d)
| summarize arg_max(TimeGenerated, Entities, Tactics, Techniques, AlertName, AlertSeverity) by SystemAlertId
// ❌ Materializes all 30+ columns
SecurityAlert
| summarize arg_max(TimeGenerated, *) by SystemAlertId
For rare key/value lookups in dynamic columns, use has to eliminate rows before expensive parse_json().
// ✅ Term filter first, JSON parse on survivors
AuditLogs
| where tostring(TargetResources) has "MyApp"
| extend Target = tostring(parse_json(tostring(TargetResources[0])).displayName)
| where Target == "MyApp"
Filtering on native columns enables index usage; calculated columns force full scans.
// ✅ Filter on native column
SecurityEvent | where EventID == 4625
// ❌ Filter on calculated column
SecurityEvent | extend Cat = case(EventID == 4625, "Fail", ...) | where Cat == "Fail"
Drop unnecessary columns before expensive operators (join, summarize, mv-expand) to reduce memory and shuffling.
take or summarize to limit resultsUnbounded queries on large tables consume excessive resources.
In AH, AuditLogs.InitiatedBy and TargetResources are native dynamic — use direct dot-notation. In Data Lake, they may be string-typed requiring parse_json().
// ✅ Advanced Hunting — direct access
| extend Actor = tostring(InitiatedBy.user.userPrincipalName)
// ✅ Data Lake — parse_json wrapper
| extend Actor = tostring(parse_json(tostring(InitiatedBy.user)).userPrincipalName)
// 🔵 Safe in both — stringify full field
| where tostring(InitiatedBy) has "[email protected]"
strcat(substring(UPN, 0, 3), "***") when appropriatelet SuspiciousIPs = ... not let x = ...let variables across queries the user will run independentlyCommon "expected string expression" error: After mv-expand, parse_json, or split, values are dynamic — string functions fail. Always convert first:
// After mv-expand
| mv-expand AuthDetails
| extend AuthMethod = tostring(AuthDetails.authenticationMethod)
// After split
| extend Parts = split(UPN, "@")
| extend Domain = tostring(Parts[1])
Rule of thumb: If you get "expected string expression", add tostring().
development
Use this skill when asked to investigate a computer, device, endpoint, or machine for security issues, suspicious activity, malware, or compliance review. Triggers on keywords like "investigate computer", "investigate device", "investigate endpoint", "check machine", "device security", "endpoint investigation", or when a device name/hostname is mentioned with investigation context. This skill provides comprehensive device security analysis including Defender alerts, sign-in patterns, logged-on users, vulnerabilities, software inventory, compliance status, network activity, and automated investigation tracking for Entra Joined, Hybrid Joined, and Entra Registered devices.
development
Recommended starting point for new users and daily SOC operations. Quick 15-minute security posture scan across 7 domains: active incidents, identity (human + NonHuman), endpoint, email threats, admin & cloud ops, and exposure. 12 queries executed in parallel batches, producing a prioritized Threat Pulse Dashboard with color-coded verdicts (🔴 Escalate / 🟠 Investigate / 🟡 Monitor / ✅ Clear) and drill-down recommendations pointing to specialized skills. Trigger on getting-started questions like "what can you do", "where do I start", "help me investigate". Supports inline chat and markdown file output
development
Use this skill when asked to investigate a user account for security issues, suspicious activity, or compliance review. Triggers on keywords like "investigate user", "security investigation", "user investigation", "check user activity", "analyze sign-ins", or when a UPN/email is mentioned with investigation context. This skill provides comprehensive Entra ID user security analysis including sign-in anomalies, MFA status, device compliance, audit logs, security incidents, Identity Protection risk, and automated reports (HTML, markdown file, or inline chat).
development
Use this skill when asked to generate SVG data visualization dashboards from investigation data or skill reports. Triggers on keywords like "generate SVG dashboard", "create a visual dashboard", "visualize this report", "SVG from the report", "visualize results", "create SVG chart", "SVG from this data". Supports two modes: manifest-driven structured dashboards (from skill reports with svg-widgets.yaml) and freeform adaptive visualizations from ad-hoc investigation data. Component library includes KPI cards, score cards, bar charts, line charts, donut charts, waterfall charts, tables, recommendation cards, assessment banners. SharePoint Dark Theme default palette.