IRQL.md Here is a factual summary of the article: IRQL is a collection of Kusto (KQL) functions designed to unify security logs behind a consistent, analyst-friendly dialect by hiding complexity like schema drift, cluster locations, and join keys behind stable, intention-revealing functions. The functions, created by Saar Ron, John Lambert, and Diana Damenova, fall into five groups (Selectors, Extractors, Enrichers, Graph-lifted variants, and External enrichment) and are designed to compose with graph investigation functions. IRQL aims to reduce cognitive load for both humans and AI by providing a shared vocabulary that makes queries shorter, easier to reason about, and more reliable. IRQL - Incident Response Query Language A collection of Kusto KQL functions that unify security logs behind a consistent, analyst-friendly dialect. IRQL encapsulates query logic in repeatable chunks, hides cluster/database locations and join keys, and projects disparate source schemas into a single, predictable schema. In addition, it represents query logic as their semantic intent via function naming. These functions were created by Saar Ron, John Lambert, and Diana Damenova. These functions were authored alongside the Lift to Graph functions https://gist.github.com/ddamenova/43696f1e7c63c66f924637e9577316ee Lift To Graph , Graph Render View , Graph Fold By Property and are designed to compose with them. Many of the IRQL primitives have a tabular form and a graph-lifted form, so the same logic drives both relational hunts and visual graph investigations. Why IRQL? KQL is a phenomenal tool for analyzing large quantities of data, but queries can get verbose quickly: - Schema drift across tables. The same concept shows up as ipAddress , IPAddress , IpAddress , ClientIp , callerIpAddress , cip . Timestamps appear as Timestamp , TIMESTAMP , ReportTime , env time , EventTime , FirstSeen . Analysts waste cycles remembering which spelling applies where. - Cluster and database sprawl. Knowing where each table lives - and which join keys connect them - is tribal knowledge that doesn't scale. - Repeated, brittle join logic. The same enrichment patterns get copy-pasted across queries and silently break when upstream schemas shift. - Wall-of-text queries. Heavy copy/paste and arcane column parsing make queries long, repetitive, and hard to review. - High cognitive load for humans and LLMs alike. Thousands of tables with inconsistent naming is hostile to analyst onboarding and to AI-assisted authoring. IRQL addresses this by hiding all of that complexity behind stable, intention-revealing functions: - Repeatable selectors encapsulate cluster/database location, join keys, and projection. - A unified projected schema EnvTime , ClientIp , Username , Hostname , Url , … makes downstream queries look the same regardless of source. - Function names describe intent, not mechanics. Enrich Ip Employee , Extract Email Sender Domain , Get Event Authentication - a pipeline reads as a sequence of meaningful operations rather than a wall of joins, projections, and regexes. Queries are shorter, easier for humans to reason about, and easier for an AI to compose correctly because the names describe what's happening semantically. - A single consistent dialect. Once you learn one selector, every other selector reads the same way. - AI-ready by construction. A cohesive domain-specific dialect is dramatically easier for an LLM to generate correctly than raw telemetry, and analyst work written in IRQL becomes higher-quality training signal. - Composes with Kusto itself. IRQL is Kusto - every function interoperates cleanly with existing queries, dashboards, and detection pipelines. Compounding benefits: faster analyst onboarding, faster query authoring, queries that better reflect their semantic intent, less copy-pasted logic, and a shared vocabulary that both humans and AI can read and write. Function Catalog IRQL functions fall into five groups: 1. Selectors - Get functions. Return a projected, renamed view of the underlying table using the unified schema. 2. Extractors - Extract functions. Transform fields from existing columns domain from URL, first name from full name, domain from email sender . 3. Enrichers - Enrich functions. Take a table with a known key column and left-join it against a primitive to add context. 4. Graph-lifted variants - Extract Node , Enrich Node , Enrich Graph . The same primitives, but operating over a graph table produced by Lift To Graph so the results materialize directly into a graph investigation. 5. External enrichment - two published functions wrapping external threat-intel sources: Enrich Sha256 VirusTotal and Get CISA KEV / Enrich CISA KEV . Examples below use the open KC7 https://kc7cyber.com JoJo's Hospital / Valdy Times datasets so you can copy, paste, and run them. Kusto connection string: https://kc7001.eastus.kusto.windows.net. --- Before and After: What IRQL Looks Like The IRQL pitch is easier to feel than to describe. Here's a real investigation query — surfacing AAD applications that are suddenly seeing sign-ins from a user-agent category they've never used before, a strong signal for token theft, OAuth abuse, and AiTM proxy activity — written first against raw telemetry, then in IRQL. The IRQL primitives shown here don't exist in the published KC7 catalog which is built around hospital and news-site datasets, not Entra ID sign-ins . They're written in the IRQL idiom to show what the dialect looks like when extended to a real Entra ID estate. Before — raw KQL kusto let minimumAppThreshold = 100; let timeframe = 1d; let lookback timeframe = 7d; let ExtractBrowserTypeFromUA = ua:string { case ua has "Edge/", dynamic {"AgentType": "Browser", "AgentName": "Edge"} , ua has "Edg/", dynamic {"AgentType": "Browser", "AgentName": "Edge"} , ua has "Trident/", dynamic {"AgentType": "Browser", "AgentName": "Internet Explorer"} , ua has "Chrome/" and ua has "Safari/", dynamic {"AgentType": "Browser", "AgentName": "Chrome"} , ua has "Gecko/" and ua has "Firefox/", dynamic {"AgentType": "Browser", "AgentName": "Firefox"} , not ua has "Mobile/" and ua has "Safari/" and ua has "Version/", dynamic {"AgentType": "Browser", "AgentName": "Safari"} , ua startswith "Dalvik/" and ua has "Android", dynamic {"AgentType": "Browser", "AgentName": "Android Browser"} , ua startswith "MobileSafari//", dynamic {"AgentType": "Browser", "AgentName": "Mobile Safari"} , ua has "Mobile/" and ua has "Safari/" and ua has "Version/", dynamic {"AgentType": "Browser", "AgentName": "Mobile Safari"} , ua has "Mobile/" and ua has "FxiOS/", dynamic {"AgentType": "Browser", "AgentName": "IOS Firefox"} , ua has "Mobile/" and ua has "CriOS/", dynamic {"AgentType": "Browser", "AgentName": "IOS Chrome"} , ua has "Mobile/" and ua has "WebKit/", dynamic {"AgentType": "Browser", "AgentName": "Mobile Webkit"} , ua startswith "Excel/", dynamic {"AgentType": "OfficeApp", "AgentName": "Excel"} , ua startswith "Outlook/", dynamic {"AgentType": "OfficeApp", "AgentName": "Outlook"} , ua startswith "OneDrive/", dynamic {"AgentType": "OfficeApp", "AgentName": "OneDrive"} , ua startswith "OneNote/", dynamic {"AgentType": "OfficeApp", "AgentName": "OneNote"} , ua startswith "Office/", dynamic {"AgentType": "OfficeApp", "AgentName": "Office"} , ua startswith "PowerPoint/", dynamic {"AgentType": "OfficeApp", "AgentName": "PowerPoint"} , ua startswith "PowerApps/", dynamic {"AgentType": "OfficeApp", "AgentName": "PowerApps"} , ua startswith "SharePoint/", dynamic {"AgentType": "OfficeApp", "AgentName": "SharePoint"} , ua startswith "Word/", dynamic {"AgentType": "OfficeApp", "AgentName": "Word"} , ua startswith "Visio/", dynamic {"AgentType": "OfficeApp", "AgentName": "Visio"} , ua startswith "Whiteboard/", dynamic {"AgentType": "OfficeApp", "AgentName": "Whiteboard"} , ua =~ "Mozilla/5.0 compatible; MSAL 1.0 ", dynamic {"AgentType": "OfficeApp", "AgentName": "Office Telemetry"} , ua has ".NET CLR", dynamic {"AgentType": "Custom", "AgentName": "Dotnet"} , ua startswith "Java/", dynamic {"AgentType": "Custom", "AgentName": "Java"} , ua startswith "okhttp/", dynamic {"AgentType": "Custom", "AgentName": "okhttp"} , ua has "Drupal/", dynamic {"AgentType": "Custom", "AgentName": "Drupal"} , ua has "PHP/", dynamic {"AgentType": "Custom", "AgentName": "PHP"} , ua startswith "curl/", dynamic {"AgentType": "Custom", "AgentName": "curl"} , ua has "python-requests", dynamic {"AgentType": "Custom", "AgentName": "Python"} , pack "AgentType", "Other", "AgentName", extract @"^ ^/ /", 1, ua let QueryUserAgents = start time:timespan, end time:timespan { union withsource=tbl name AADNonInteractiveUserSignInLogs, SigninLogs | where TimeGenerated between start time .. end time | where ResultType == 0 | extend ParsedUserAgent = ExtractBrowserTypeFromUA UserAgent | extend UserAgentType = tostring ParsedUserAgent.AgentType | extend UserAgentName = tostring ParsedUserAgent.AgentName | extend SimpleUserAgent = UserAgentType | where not isempty UserAgent | where not isempty AppId }; let BaselineUserAgents = materialize QueryUserAgents lookback timeframe + timeframe, timeframe | summarize RequestCount = count by AppId, AppDisplayName, SimpleUserAgent ; let BaselineSummarizedAgents = BaselineUserAgents | summarize BaselineUAs = make set SimpleUserAgent , BaselineRequestCount = sum RequestCount by AppId, AppDisplayName ; QueryUserAgents timeframe, 0d | summarize count by AppId, AppDisplayName, UserAgent, SimpleUserAgent | join kind=leftanti BaselineUserAgents on AppId, AppDisplayName, SimpleUserAgent | join BaselineSummarizedAgents on AppId, AppDisplayName | where BaselineRequestCount minimumAppThreshold | join QueryUserAgents timeframe, 0d on AppId, UserAgent | project-away ParsedUserAgent, UserAgentName | project-reorder TimeGenerated, AppDisplayName, UserPrincipalName, UserAgent, BaselineUAs | summarize count by UserPrincipalName, AppDisplayName, AppId, UserAgentType, SimpleUserAgent, UserAgent The investigation question — which apps are seeing brand-new UA categories today vs. the past week? — is buried under a 30-branch user-agent classifier inlined as a lambda, a manual union across two sign-in tables, a parameterized subquery invoked at three different time windows, and three explicit joins to stitch baseline against current. The mechanics dominate the intent. After — IRQL kusto let minimumAppThreshold = 100; let timeframe = 1d; let lookback = 7d; let SignInsToday = Get Event SignIn timeframe, 0d ; let SignInsBaseline = Get Event SignIn lookback + timeframe, timeframe ; let Baseline = SignInsBaseline | invoke Extract SignIn UserAgent Category | summarize RequestCount = count by AppId, AppDisplayName, UserAgentCategory; SignInsToday | invoke Extract SignIn UserAgent Category | invoke Enrich App New UserAgent Category Baseline, minimumAppThreshold | project EnvTime, Username, AppDisplayName, AppId, UserAgentCategory, UserAgent, BaselineCategories | summarize count by Username, AppDisplayName, AppId, UserAgentCategory, UserAgent Same question, expressed as named operations. Get Event SignIn is a selector in the Get Event family — it hides the union of AADNonInteractiveUserSignInLogs and SigninLogs , the success filter, the empty-field guards, and projects into the unified schema. Extract SignIn UserAgent Category wraps the 30-branch classifier and adds a single UserAgentCategory column, mirroring how Extract Email Sender Domain adds a Domain column. Enrich App New UserAgent Category encapsulates the baseline-vs-current diff: the leftanti join, the per-app threshold filter, and the BaselineCategories set that lets analysts see what was normal alongside what's new. The mechanical work is identical — same union, same classifier, same anti-join, same threshold. The difference is that none of it is in the analyst's face anymore. It lives once, inside the function definitions, and every hunt that touches AAD sign-ins reuses it. The pipeline reads as a sequence of meaningful operations rather than a wall of joins and regex, and an LLM composing a follow-up query has intent-revealing primitives to compose with instead of having to reconstruct the pattern from scratch. --- 1. Selectors - Get Each Get function returns a projected view of a source table using the unified schema. Raw security tables typically carry dozens of columns, most of which are irrelevant to any given hunt - agent versions, internal correlation IDs, redundant timestamp variants, schema-versioning fields, and so on. The default Get form down-projects to just the columns analysts actually reach for day-to-day, renamed into the unified schema. The result is a narrower, more readable table that keeps queries focused and avoids drowning the analyst or an LLM composing a query in columns they'll never use. When a hunt legitimately needs the fuller picture, every primitive has a Get All companion that returns the same rows with a wider field set - still renamed into the unified schema, but without the trimming. The convention is simple: start with the minimal view, and opt into All only when a specific investigation requires it. | Function | Returns | | --- | --- | | Get Event Authentication | Authentication events - EnvTime , Hostname , ClientIp , Username , Result | | Get Event Authentication All | Full auth events including Description , UserAgent , PasswordHash | | Get Email | Email events - EnvTime , EmailSender , EmailRecipient , Subject , Url | | Get Email All | Full email events including ReplyTo , Verdict | | Get Employees | Employee directory - Name , ClientIp , Email , Username , Hostname , Role | | Get Employees All | Full employee directory including HireDate , UserAgent , Domain | | Get Event FileCreation | File creation events - EnvTime , Hostname , Filename , Path | | Get Event FileCreation All | Full file creation events including Username , Sha256 , ProcessName | | Get Event NetworkInbound | Inbound network events - EnvTime , ClientIp , Url | | Get Event NetworkInbound All | Full inbound including Method , UserAgent , StatusCode | | Get Event NetworkOutbound | Outbound network events - EnvTime , ClientIp , Url | | Get Event NetworkOutbound All | Full outbound including Method , UserAgent | | Get Dns All | Passive DNS - EnvTime , Domain , ClientIp | | Get Event Process | Process events - EnvTime , ProcessCommandLine , ProcessName , Hostname , Username | | Get Event Process All | Full process events including ParentProcessName , ParentProcessHash , ProcessHash | | Get SecurityAlerts All | Security alerts - EnvTime , AlertType , Severity , Description , Indicators | | Get Network Connection All | Network flow records - EnvTime , SourceIp , SourcePort , DestinationIp , DestinationPort , Protocol , Bytes | Usage: kusto Get Event NetworkOutbound | summarize count by ClientIp, Url | top 20 by count No cluster string. No project-rename . No memorization of where OutboundNetworkEvents actually lives. --- 2. Extractors - Extract Functions that derive a new column by transforming an existing one. Many security queries require transformations from one column to another, an example in the Defender XDR schema is transforming a RequestId into a unique token identifier. | Function | Input | Adds | | --- | --- | --- | | Extract Email Sender Domain T | EmailSender:string | Domain | | Extract Employee Firstname T | Name:string | Firstname | | Extract Event Network Domain T | Url:string | DomainName | Usage: kusto Get Email | invoke Extract Email Sender Domain | summarize count by Domain --- 3. Enrichers - Enrich Left-join helpers. Give them a table with the IRQL standardized schema and they'll attach the related context from the relevant primitive. | Function | Key column | Enriches with | | --- | --- | --- | | Enrich Event Authentication Username T | Username | Authentication events for each user | | Enrich Ip Employee T | ClientIp | Employee identity from IP | | Enrich Username Employee T | Username | Employee identity from username | | Enrich Ip Domain T | ClientIp | DNS domains resolved to each IP | | Enrich Ip Event NetworkOutbound T | ClientIp | Outbound network events from each IP | | Enrich Ip Network Connection T | ClientIp | Network flow records sourced from each IP | Usage: kusto Get Event Authentication | where Result == "Failed Login" | summarize FailedCount = count by Username | where FailedCount 19 | invoke Enrich Username Employee | project Username, Name, Role, Email, FailedCount Chain them together freely: kusto Get Event NetworkOutbound | invoke Extract Event Network Domain | invoke Enrich Ip Employee | where Role has any "Executive", "CEO", "Chief", "Director" | project EnvTime, Name, DomainName, Url, Role --- 4. Graph-Lifted Variants The same IRQL primitives, surfaced as node/edge enrichers so they compose directly with Lift To Graph https://gist.github.com/ddamenova/43696f1e7c63c66f924637e9577316ee . These operate on a graph table the output of Lift To Graph rather than a raw row-oriented table. There are two flavors: - Extract Node / Enrich Node - adds new properties to existing nodes in place. The graph shape nodes, edges doesn't change; the nodes just get richer property bags and optionally updated display names. - Enrich Graph - expands the graph by lifting new nodes and edges from the enrichment results, then unions them deduplicated with the original graph and wires up "Related To" edges between nodes that share the enrichment key. Extract Node in-place property enrichment | Function | Target property | Adds to node properties | | --- | --- | --- | | Extract Node Email Sender Domain T, newDisplayName | EmailSender | Domain | | Extract Node Employee Firstname T, newDisplayName | Name | Firstname | | Extract Node Event Network Domain T, newDisplayName | Url | DomainName | Enrich Node in-place property enrichment via join | Function | Key property | Adds | | --- | --- | --- | | Enrich Node Event Authentication Username T, newDisplayName | Username | EnvTime , Hostname , ClientIp , Result | | Enrich Node Ip Employee T, newDisplayName | ClientIp | Name , Email , Username , Hostname , Role | | Enrich Node Username Employee T, newDisplayName | Username | Name , ClientIp , Email , Hostname , Role | | Enrich Node Ip Network Connection T, newDisplayName | ClientIp | EnvTime , SourceIp , SourcePort , DestinationIp , DestinationPort , Protocol , Bytes | | Enrich Node Ip Domain T, newDisplayName | ClientIp | EnvTime , Domain | | Enrich Node Ip Event NetworkOutbound T, newDisplayName | ClientIp | EnvTime , Url | The optional newDisplayName parameter lets you re-label a node using any field brought in by the enrichment - e.g. passing "Name" to Enrich Node Ip Employee relabels IP nodes with the owning employee's name. Enrich Graph structural graph expansion | Function | Key property | Lifts into graph | | --- | --- | --- | | Enrich Graph Event Authentication Username T, mappingJson | Username | New auth-event nodes + edges | | Enrich Graph Ip Employee T, mappingJson | ClientIp | New employee nodes + edges | | Enrich Graph Username Employee T, mappingJson | Username | New employee nodes + edges | | Enrich Graph Ip Network Connection T, mappingJson | ClientIp | New network-flow nodes + edges | | Enrich Graph Ip Event NetworkOutbound T, mappingJson | ClientIp | New outbound-network-event nodes + edges | Each Enrich Graph function takes its own mappingJson same schema as Lift To Graph describing how the newly fetched rows should be lifted into nodes and edges. The result is union ed with the input graph via Graph Merge Dedup , If a mappingJson is not passed in, any pair of nodes sharing the enrichment key is linked with a Related To edge. --- 5. External Enrichment Many security teams have disparate datasets. IRQL enables us to create a catalog and centralized schema and language to access these datasets. An example of a disparate dataset is the open source security datasets that are HTTPS accessible and thus Kusto accessible with an API key. This section introduces two functions, each targeting a different external source. For each of these you will need to enable them to add the domains to the callout policy white list: kql .alter cluster policy callout @' { "CalloutType": "webapi", "CalloutUriRegex": "www\\.virustotal\\.com/vtapi/. ", "CanCall": true } ' | Function | Source | Key | Shape | | --- | --- | --- | --- | | Enrich Sha256 VirusTotal T | VirusTotal file/report | Sha256 | Batched GET, comma-separated hashes | | Enrich CISA KEV T | CISA Known Exploited Vulns catalog | CveId | Static JSON feed, no auth | Both depend on http request / http request post , which is cluster-policy-gated - an admin needs to allowlist the destination domains before the functions will run. Note on the KC7 examples. KC7's JoJosHospital and ValdyTimes datasets are synthetic - the datasets may not return matches when queried against VirusTotal or the CISA KEV catalog. The external-enrichment functions in this section are meant to be deployed on your own cluster against your own real telemetry. Copy the function definitions into your production database, allowlist the callout domains, and point them at live detection data - that's where the enrichment actually pays off. VirusTotal batched GET, comma-separated hashes VirusTotal's /vtapi/v2/file/report endpoint accepts a comma-separated list of resources in a single GET, so the whole batch fits in one http request call - no mv-apply per-row fanout needed. The result is unpacked once and projected into a flat scan-result table. Free API key at