Initial commit: KQL ↔ SDL PowerQuery proof of equivalence

This commit is contained in:
marc
2026-06-01 09:57:14 +02:00
commit 23cbaa9c08
91 changed files with 5966 additions and 0 deletions
@@ -0,0 +1,41 @@
// 90-day cross-source hunt: failed Entra sign-ins ∧ bad-domain DNS ∧
// LOLBin process execution on the SAME user/host.
//
// Paste into a Microsoft Sentinel workspace that has SigninLogs +
// _Im_Dns + _Im_ProcessEvent populated.
//
// Known cliffs on a real workspace at 90d:
// * memory pressure on _Im_ProcessEvent
// * has_any on ProcessCommandLine bypasses the term index
// * hint.shufflekey is required to avoid OOM on the cross-table join
let suspect_domains = dynamic([
"c2.example.com",
"suspect.example.net"
]);
let lolbins = dynamic([
"powershell", "rundll32", "mshta"
]);
let suspicious_users =
SigninLogs
| where TimeGenerated > ago(90d)
| where ResultType != 0 or RiskLevelDuringSignIn == "high"
| summarize FailedSignins = count() by UserPrincipalName;
let bad_dns =
_Im_Dns(starttime=ago(90d))
| where DnsQuery has_any (suspect_domains)
| project TimeGenerated, SrcIpAddr, DnsQuery, UserName = SrcUsername;
_Im_ProcessEvent(starttime=ago(90d))
| where ProcessCommandLine has_any (lolbins)
| join kind=inner hint.strategy=shuffle hint.shufflekey=DvcHostname (
bad_dns
| extend DvcHostname = tostring(SrcIpAddr)
) on DvcHostname
| join kind=inner (suspicious_users)
on $left.ActorUsername == $right.UserPrincipalName
| summarize Hits = count(),
Domains = make_set(DnsQuery, 20),
Cmdlines = make_set(ProcessCommandLine, 20)
by ActorUsername, DvcHostname
| order by Hits desc
| take 100
@@ -0,0 +1,63 @@
// Same 90-day cross-source hunt expressed in SentinelOne SDL PowerQuery.
//
// Paste into a SDL tenant (e.g. https://xdr.us1.sentinelone.net) with
// `startTime = "90d"`. Replace the synthetic domain regex if you have
// a real IOC list.
//
// Why this looks different from the KQL:
// * one schema (OCSF) instead of three runtime parser unions
// * one engine path; 90d is more epochs scanned in parallel, NOT a
// different code path (no Basic/Auxiliary/Archive tiers)
// * the join is a single named-input join; no shuffle hint required
// because reduction is already distributed
//
// NOTE on `loginIsSuccessful = 'false'`:
// SDL stores booleans as lowercase strings. On a tenant whose OCSF
// parser emits true/false as native booleans, drop the quotes:
// AND event.login.loginIsSuccessful = false
| join
failed_signins = (
event.category = 'logins'
AND event.login.loginIsSuccessful = 'false'
| columns userName = event.login.userName,
host = endpoint.name
| group n_fails = count() by userName, host
),
bad_dns = (
event.type = 'DNS Resolved'
AND dns.question.name matches '(c2|suspect)\.example\.'
| columns userName = src.endpoint.user.name,
host = endpoint.name,
domain = dns.question.name
| group dns_hits = count(),
domains = array_agg_distinct(domain, 20)
by userName, host
),
susp_proc = (
event.type = 'Process Creation'
AND src.process.cmdline matches '(?i)(powershell|rundll32|mshta)'
| columns userName = src.process.user,
host = endpoint.name,
cmdline = src.process.cmdline
| group proc_hits = count(),
cmdlines = array_agg_distinct(cmdline, 20)
by userName, host
)
on userName, host
| columns userName,
host,
hits = n_fails + dns_hits + proc_hits,
n_fails,
dns_hits,
proc_hits,
domains,
cmdlines
// If your tenant complains about bare field names after the join, use the
// fully-prefixed form instead:
// | columns userName, host,
// hits = failed_signins.n_fails + bad_dns.dns_hits + susp_proc.proc_hits,
// failed_signins.n_fails, bad_dns.dns_hits, susp_proc.proc_hits,
// bad_dns.domains, susp_proc.cmdlines
| sort -hits
| limit 100