mirror of
https://github.com/marcredhat/kql
synced 2026-06-08 13:23:58 +00:00
Initial commit: KQL ↔ SDL PowerQuery proof of equivalence
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user