SIEM Detection Rule Development

SkillMonitoring & ops

Guides development of SIEM detection rules using KQL (Microsoft Sentinel) and SPL (Splunk) query languages, mapped to MITRE ATT&CK v16 techniques. Auto-invoked when the user needs to write SIEM queries, tune alert thresholds, build correlation rules, or manage the detection rule lifecycle. Produces production-ready queries with detection logic patterns, threshold tuning guidance, and lifecycle management.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the SIEM Detection Rule Development skill

What this skill tells your AI

The instructions your AI receives, as published by unitoneai/securityskills in skills/secops/siem-rules/SKILL.md and read by ahel’s review.

Framework: MITRE ATT&CK v16 Role: SOC Analyst, Security Engineer Time: 20-40 min per rule Output: Production-ready KQL or SPL detection query, correlation rule logic, tuning parameters


1. When to Use

If a target is provided via arguments, focus the review on: $ARGUMENTS

Invoke this skill when any of the following conditions are met:

  • SIEM rule authoring -- A new detection rule needs to be written in KQL (Microsoft Sentinel) or SPL (Splunk) for a specific threat scenario.
  • Sigma rule conversion review -- A Sigma rule has been converted to KQL or SPL and needs manual review, optimization, or platform-specific tuning.
  • Alert threshold tuning -- An existing rule is generating too many false positives or too few true positives and requires threshold or logic adjustments.
  • Correlation rule design -- Multiple log sources need to be joined or correlated to produce a higher-fidelity detection.
  • Detection rule lifecycle management -- Rules need to be reviewed, versioned, promoted, deprecated, or retired following a structured lifecycle.
  • Query performance optimization -- A detection query is consuming excessive resources or timing out and requires optimization.

Do not use when: The task is writing platform-agnostic Sigma rules (use detection-engineering), performing alert triage on a fired alert (use alert-triage), or analyzing raw logs for forensic investigation (use log-analysis).


2. Context the Agent Needs

Before beginning, gather or confirm:

  • Target SIEM platform: Microsoft Sentinel (KQL) or Splunk (SPL).
  • Detection objective: What behavior or threat is being detected? Include ATT&CK technique ID if known.
  • Available data tables/indexes: Which log tables (Sentinel) or indexes (Splunk) contain the relevant data?
  • Environment baseline: Normal volume and patterns for the data source (e.g., average daily failed logon count, typical admin logon hours).
  • Alert priority and response: Desired severity level and expected analyst response procedure.
  • Performance constraints: Query time window, maximum execution time, and scheduled frequency.
  • Existing rules: Any current rules covering similar detections that may overlap or conflict.

3. Process

Step 1: Detection Pattern Selection

Select the appropriate detection logic pattern based on the threat being detected.

Core detection patterns:

PatternUse CaseComplexity
Simple matchKnown-bad indicators, specific event IDsLow
ThresholdBrute force, scanning, volume anomaliesLow-Medium
Time windowRapid successive events, timing-based attacksMedium
AggregationGroup-by analysis, frequency countingMedium
CorrelationMulti-table joins, multi-stage attacksHigh
Behavioral baselineDeviation from normal, first-seen analysisHigh
Impossible travelGeographically implausible authenticationHigh

Step 2: Write the Detection Query

KQL (Microsoft Sentinel) Syntax Reference

Common Sentinel tables:

TableData SourceKey Fields
SigninLogsAzure AD interactive sign-insUserPrincipalName, ResultType, IPAddress, Location
AADNonInteractiveUserSignInLogsAzure AD non-interactive sign-insSame as SigninLogs
SecurityEventWindows Security Event LogEventID, Account, Computer, Activity
SyslogLinux syslogSyslogMessage, ProcessName, Facility, SeverityLevel
DeviceProcessEventsMicrosoft Defender for EndpointFileName, ProcessCommandLine, InitiatingProcessFileName
DeviceNetworkEventsMDE network eventsRemoteIP, RemotePort, RemoteUrl
AzureActivityAzure control planeOperationNameValue, Caller, ResourceGroup
CommonSecurityLogCEF-format logs (firewalls, proxies)DeviceAction, SourceIP, DestinationIP
ThreatIntelligenceIndicatorThreat intel feedsNetworkIP, DomainName, Url, ExpirationDateTime
OfficeActivityMicrosoft 365 audit logsOperation, UserId, ClientIP

Detection: Brute Force -- Password Spray (KQL)

ATT&CK: T1110.003 -- Brute Force: Password Spraying

// Password Spray Detection -- Multiple accounts, same source, failed logins
// ATT&CK: T1110.003 -- Brute Force: Password Spraying
// Sentinel Table: SigninLogs
// Threshold: 10+ distinct accounts with failed auth from same IP in 10 minutes
let threshold_accounts = 10;
let threshold_window = 10m;
SigninLogs
| where TimeGenerated > ago(1h)
| where ResultType in ("50126", "50053", "50055", "50056")  // Failed password, locked, expired, etc.
| summarize
    DistinctAccounts = dcount(UserPrincipalName),
    AttemptCount = count(),
    TargetAccounts = make_set(UserPrincipalName, 50),
    FirstAttempt = min(TimeGenerated),
    LastAttempt = max(TimeGenerated)
    by IPAddress, bin(TimeGenerated, threshold_window)
| where DistinctAccounts >= threshold_accounts
| extend AttackDuration = LastAttempt - FirstAttempt
| project
    TimeGenerated,
    IPAddress,
    DistinctAccounts,
    AttemptCount,
    AttackDuration,
    TargetAccounts
| sort by DistinctAccounts desc

Key ResultType values (Azure AD):

ResultTypeMeaning
0Success
50126Invalid username or password
50053Account locked
50055Password expired
50056Invalid or null password
50057Account disabled
50074MFA required
50076MFA prompt not satisfied
53003Conditional access block

Detection: Impossible Travel (KQL)

ATT&CK: T1078 -- Valid Accounts

// Impossible Travel Detection
// ATT&CK: T1078 -- Valid Accounts (compromised credentials)
// Detects successful logins from geographically distant locations within
// a time window that makes physical travel impossible
let travel_speed_kmh = 900;  // Maximum plausible travel speed (commercial flight)
let min_distance_km = 500;   // Minimum distance to flag (avoids VPN/proxy noise)
let time_window = 24h;
SigninLogs
| where TimeGenerated > ago(time_window)
| where ResultType == 0  // Successful logins only
| where isnotempty(LocationDetails.geoCoordinates.latitude)
| extend
    Latitude = todouble(LocationDetails.geoCoordinates.latitude),
    Longitude = todouble(LocationDetails.geoCoordinates.longitude),
    City = tostring(LocationDetails.city),
    Country = tostring(LocationDetails.countryOrRegion)
| sort by UserPrincipalName asc, TimeGenerated asc
| serialize
| extend
    PrevLatitude = prev(Latitude, 1),
    PrevLongitude = prev(Longitude, 1),
    PrevTime = prev(TimeGenerated, 1),
    PrevCity = prev(City, 1),
    PrevCountry = prev(Country, 1),
    PrevUser = prev(UserPrincipalName, 1)
| where UserPrincipalName == PrevUser
| extend
    TimeDiffHours = datetime_diff('minute', TimeGenerated, PrevTime) / 60.0,
    // Haversine formula for distance calculation
    DistanceKm = 2 * 6371 * asin(sqrt(
        sin(radians((Latitude - PrevLatitude) / 2)) * sin(radians((Latitude - PrevLatitude) / 2)) +
        cos(radians(PrevLatitude)) * cos(radians(Latitude)) *
        sin(radians((Longitude - PrevLongitude) / 2)) * sin(radians((Longitude - PrevLongitude) / 2))
    ))
| where DistanceKm >= min_distance_km
| extend RequiredSpeedKmh = iff(TimeDiffHours > 0, DistanceKm / TimeDiffHours, real(99999))
| where RequiredSpeedKmh > travel_speed_kmh
| project
    TimeGenerated,
    UserPrincipalName,
    CurrentLocation = strcat(City, ", ", Country),
    PreviousLocation = strcat(PrevCity, ", ", PrevCountry),
    TimeDiffHours = round(TimeDiffHours, 1),
    DistanceKm = round(DistanceKm, 0),
    RequiredSpeedKmh = round(RequiredSpeedKmh, 0),
    IPAddress

Detection: Privileged Account Usage Outside Business Hours (KQL)

ATT&CK: T1078.002 -- Valid Accounts: Domain Accounts

// Privileged Account Usage Outside Business Hours
// ATT&CK: T1078.002 -- Valid Accounts: Domain Accounts
// Detects privileged account logins outside defined business hours
let business_start = 7;   // 7 AM
let business_end = 19;    // 7 PM
let weekend_days = dynamic(["Saturday", "Sunday"]);
let privileged_patterns = dynamic(["admin", "svc-", "sa-", "break-glass", "emergency"]);
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType == 0
| extend
    HourOfDay = hourofday(TimeGenerated),
    DayOfWeek = dayofweek(TimeGenerated),
    DayName = case(
        dayofweek(TimeGenerated) == 0d, "Sunday",
        dayofweek(TimeGenerated) == 1d, "Monday",
        dayofweek(TimeGenerated) == 2d, "Tuesday",
        dayofweek(TimeGenerated) == 3d, "Wednesday",
        dayofweek(TimeGenerated) == 4d, "Thursday",
        dayofweek(TimeGenerated) == 5d, "Friday",
        dayofweek(TimeGenerated) == 6d, "Saturday",
        "Unknown")
| where HourOfDay < business_start or HourOfDay >= business_end
    or DayName in (weekend_days)
| where UserPrincipalName has_any (privileged_patterns)
| project
    TimeGenerated,
    UserPrincipalName,
    HourOfDay,
    DayName,
    IPAddress,
    AppDisplayName,
    LocationDetails.city,
    LocationDetails.countryOrRegion,
    ConditionalAccessStatus

SPL (Splunk) Syntax Reference

Common Splunk sourcetypes:

SourcetypeData SourceKey Fields
WinEventLog:SecurityWindows Security Event LogEventCode, Account_Name, ComputerName
WinEventLog:SystemWindows System Event LogEventCode, SourceName
XmlWinEventLog:Microsoft-Windows-Sysmon/OperationalSysmonEventCode, Image, CommandLine, ParentImage
linux_secure/var/log/secure (RHEL/CentOS)action, user, src_ip
linux_auditauditd logstype, uid, exe, key
pan:trafficPalo Alto firewallsrc_ip, dest_ip, dest_port, action
aws:cloudtrailAWS CloudTraileventName, sourceIPAddress, userIdentity.arn
o365:management:activityMicrosoft 365Operation, UserId, ClientIP

Detection: Brute Force -- Password Spray (SPL)

ATT&CK: T1110.003 -- Brute Force: Password Spraying

`comment("Password Spray Detection -- ATT&CK T1110.003")`
`comment("Detects multiple distinct accounts with failed auth from same source IP")`
index=wineventlog sourcetype="WinEventLog:Security" EventCode=4625
| bin _time span=10m
| stats
    dc(TargetUserName) as distinct_accounts,
    count as attempt_count,
    values(TargetUserName) as target_accounts,
    earliest(_time) as first_attempt,
    latest(_time) as last_attempt
    by IpAddress, _time
| where distinct_accounts >= 10
| eval attack_duration_sec = last_attempt - first_attempt
| eval first_attempt = strftime(first_attempt, "%Y-%m-%d %H:%M:%S")
| eval last_attempt = strftime(last_attempt, "%Y-%m-%d %H:%M:%S")
| sort - distinct_accounts
| table _time, IpAddress, distinct_accounts, attempt_count, attack_duration_sec, target_accounts

Detection: Impossible Travel (SPL)

ATT&CK: T1078 -- Valid Accounts

`comment("Impossible Travel Detection -- ATT&CK T1078")`
`comment("Detects logins from geographically distant locations within implausible time")`
index=o365 sourcetype="o365:management:activity" Operation=UserLoggedIn
| iplocation ClientIP
| where isnotnull(lat) AND isnotnull(lon)
| sort 0 UserId _time
| streamstats current=f window=1
    last(lat) as prev_lat,
    last(lon) as prev_lon,
    last(_time) as prev_time,
    last(City) as prev_city,
    last(Country) as prev_country,
    last(ClientIP) as prev_ip
    by UserId
| where isnotnull(prev_lat)
| eval time_diff_hours = (_time - prev_time) / 3600
| eval distance_km = 2 * 6371 * asin(sqrt(
    pow(sin((lat - prev_lat) * pi() / 360), 2) +
    cos(prev_lat * pi() / 180) * cos(lat * pi() / 180) *
    pow(sin((lon - prev_lon) * pi() / 360), 2)
    ))
| where distance_km >= 500
| eval required_speed_kmh = if(time_diff_hours > 0, distance_km / time_diff_hours, 99999)
| where required_speed_kmh > 900
| eval current_location = City . ", " . Country
| eval previous_location = prev_city . ", " . prev_country
| table _time, UserId, current_location, previous_location,
    time_diff_hours, distance_km, required_speed_kmh, ClientIP, prev_ip

Detection: Privileged Account Usage Outside Business Hours (SPL)

ATT&CK: T1078.002 -- Valid Accounts: Domain Accounts

`comment("Privileged Account Off-Hours Logon -- ATT&CK T1078.002")`
`comment("Detects privileged account logins outside business hours")`
index=wineventlog sourcetype="WinEventLog:Security" EventCode=4624
    (TargetUserName="admin*" OR TargetUserName="svc-*" OR TargetUserName="sa-*")
| eval hour = strftime(_time, "%H")
| eval day_of_week = strftime(_time, "%A")
| where (hour < 7 OR hour >= 19)
    OR (day_of_week="Saturday" OR day_of_week="Sunday")
| stats
    count as logon_count,
    values(IpAddress) as source_ips,
    values(WorkstationName) as workstations,
    earliest(_time) as first_seen,
    latest(_time) as last_seen
    by TargetUserName, LogonType
| eval first_seen = strftime(first_seen, "%Y-%m-%d %H:%M:%S")
| eval last_seen = strftime(last_seen, "%Y-%m-%d %H:%M:%S")
| eval logon_type_desc = case(
    LogonType=2, "Interactive",
    LogonType=3, "Network",
    LogonType=4, "Batch",
    LogonType=5, "Service",
    LogonType=7, "Unlock",
    LogonType=8, "NetworkCleartext",
    LogonType=9, "NewCredentials",
    LogonType=10, "RemoteInteractive",
    LogonType=11, "CachedInteractive",
    true(), "Unknown"
    )
| sort - logon_count
| table TargetUserName, logon_type_desc, logon_count, source_ips, workstations, first_seen, last_seen

Step 3: Correlation Rule Design

Correlation rules join data across multiple log sources or detect multi-stage attack sequences.

Correlation pattern: KQL join example -- Failed Logins Followed by Success

// Successful login preceded by multiple failures (credential guessing success)
// ATT&CK: T1110 -- Brute Force
let failure_threshold = 5;
let correlation_window = 15m;
let failures = SigninLogs
    | where TimeGenerated > ago(1h)
    | where ResultType != 0
    | summarize
        FailureCount = count(),
        FailureCodes = make_set(ResultType),
        FirstFailure = min(TimeGenerated)
        by UserPrincipalName, IPAddress;
let successes = SigninLogs
    | where TimeGenerated > ago(1h)
    | where ResultType == 0
    | project SuccessTime = TimeGenerated, UserPrincipalName, IPAddress,
        AppDisplayName, LocationDetails;
failures
| where FailureCount >= failure_threshold
| join kind=inner (successes) on UserPrincipalName, IPAddress
| where SuccessTime > FirstFailure
| where SuccessTime - FirstFailure <= correlation_window
| project
    SuccessTime,
    UserPrincipalName,
    IPAddress,
    FailureCount,
    FailureCodes,
    AppDisplayName,
    LocationDetails

Correlation pattern: SPL transaction example -- Lateral Movement Chain

`comment("Lateral Movement Chain Detection -- ATT&CK T1021")`
`comment("Detects a single account authenticating to 3+ hosts within 30 minutes")`
index=wineventlog sourcetype="WinEventLog:Security" EventCode=4624 LogonType=3
| bin _time span=30m
| stats
    dc(Computer) as distinct_hosts,
    values(Computer) as target_hosts,
    values(IpAddress) as source_ips,
    count as logon_count
    by TargetUserName, _time
| where distinct_hosts >= 3
| sort - distinct_hosts
| table _time, TargetUserName, distinct_hosts, logon_count, target_hosts, source_ips

Step 4: Alert Threshold Tuning

Tuning methodology:

  1. Baseline: Run the query in search mode for 7-30 days without alerting. Record the result count distribution.
  2. Statistical analysis: Calculate mean, median, and standard deviation of the daily/hourly result count.
  3. Threshold selection: Set the initial threshold at mean + 2 standard deviations to capture anomalous activity while filtering normal variance.
  4. Iterative tuning: After deployment, review alerts weekly for the first month. Adjust the threshold based on TP/FP ratio.
  5. Exclusion management: Add exclusions for confirmed legitimate activity. Document each exclusion with a ticket reference and review date.

Threshold tuning parameters:

ParameterPurposeExample
count thresholdMinimum event count to trigger>= 10 failed logins
distinct count thresholdMinimum unique values>= 5 distinct accounts
time windowAggregation period10m, 1h, 24h
lookback periodHistorical data to evaluateago(1h), ago(24h)
frequencyHow often the rule runsEvery 5m, 15m, 1h
suppression windowCooldown after firing to prevent duplicate alerts1h, 4h, 24h

KQL alert rule scheduling (Sentinel Analytics Rule):

Query frequency:     5 minutes
Query period:        1 hour (lookback)
Alert threshold:     Greater than 0
Event grouping:      Trigger alert for each event / Group all events
Suppression:         Enabled, 1 hour
Entity mapping:      Account -> UserPrincipalName, IP -> IPAddress, Host -> Computer

Step 5: Detection Rule Lifecycle Management

Lifecycle stages:

StageStatusDescriptionActions
DraftDevelopmentRule is being written and reviewedPeer review, logic validation
TestingExperimentalRule is deployed in non-alerting modeMonitor output, validate true positives, measure FP rate
ActiveProductionRule is alerting analystsMonitor TP/FP ratio, tune thresholds, track MTTD
TuningMaintenanceRule requires adjustmentAdd exclusions, modify thresholds, update logic
DeprecatedEnd-of-lifeRule is being phased out (replaced or obsolete)Disable alerting, retain for historical queries
RetiredArchivedRule is no longer in useRemove from active rule set, archive documentation

Rule health metrics to track:

MetricTargetRed Flag
True Positive rate> 80%< 50%
Mean Time to Detect (MTTD)< 15 min> 1 hour
Alert volume per dayManageable by team> 50 alerts/day per analyst
Last triggered dateWithin 90 days> 180 days (rule may be stale or ineffective)
Query execution time< 30 seconds> 2 minutes (performance issue)
Exclusion count< 10> 20 (rule may need fundamental redesign)

Quarterly review checklist:

  1. Is the rule still detecting a relevant threat?
  2. Has the ATT&CK technique mapping been updated for the latest ATT&CK version?
  3. Are the log sources still available and ingesting correctly?
  4. Has the TP/FP ratio changed significantly?
  5. Are there new exclusions needed or obsolete exclusions to remove?
  6. Has the threat landscape changed in ways that require rule logic updates?

4. Findings Classification

SeverityLabelDefinitionSLA
P1CriticalDetection gap for an actively exploited technique with no SIEM coverage. Available log sources exist to build the rule.Develop and deploy within 24 hours
P2HighDetection rule exists but has a high false negative rate or is disabled due to performance issues.Fix and redeploy within 7 days
P3MediumDetection rule needs tuning (high FP rate) or coverage improvement (missing sub-technique variants).Tune within 30 days
P4LowRule health metric outside target range (stale rule, high exclusion count). No immediate security impact.Review within 90 days

5. Output Format

Produce SIEM rule deliverables in this structure:

## SIEM Detection Rule: [Rule Name]
**Date:** [YYYY-MM-DD]
**Skill:** siem-rules v1.0.0
**Framework:** MITRE ATT&CK v16
**Platform:** [Microsoft Sentinel (KQL) | Splunk (SPL)]

### Rule Metadata
| Field | Value |
|-------|-------|
| Rule Name | [Name] |
| ATT&CK Technique | [T1110.003 -- Brute Force: Password Spraying] |
| ATT&CK Tactic | [Credential Access (TA0006)] |
| Severity | [High / Medium / Low / Informational] |
| Data Source | [Table/Index name] |
| Status | [Draft / Testing / Active] |

### Detection Query
[Full KQL or SPL query]

### Threshold Configuration
| Parameter | Value | Rationale |
|-----------|-------|-----------|
| Count threshold | [N] | [Why this value] |
| Time window | [Xm/h] | [Why this window] |
| Frequency | [Xm/h] | [How often to run] |
| Suppression | [Xh] | [Cooldown period] |

### Entity Mapping
| Entity Type | Source Field |
|-------------|-------------|
| Account | [UserPrincipalName / TargetUserName] |
| IP | [IPAddress / IpAddress] |
| Host | [Computer / ComputerName] |

### Known False Positives
- [List specific FP sources]

### Tuning Guidance
- [Specific tuning recommendations]

### Validation
- [How to test the rule produces a true positive]

6. Framework Reference

MITRE ATT&CK v16

For SIEM rule development, ATT&CK provides the canonical mapping between adversary techniques and the data sources that reveal them. Each technique's "Detection" section describes what to look for and in which log sources.

Key ATT&CK techniques frequently detected via SIEM rules:

Technique IDNamePrimary SIEM Data Source
T1110Brute ForceAuthentication logs (SigninLogs, EventCode 4625)
T1078Valid AccountsAuthentication logs, impossible travel
T1059Command and Scripting InterpreterProcess creation logs (Sysmon 1, 4688)
T1021Remote ServicesNetwork logon events (4624 Type 3/10)
T1053Scheduled Task/JobEvent IDs 4698 (created), 4702 (updated)
T1136Create AccountEvent ID 4720 (user account created)
T1098Account ManipulationEvent IDs 4728, 4732, 4756 (group membership changes)
T1070Indicator RemovalEvent ID 1102 (audit log cleared)
T1003OS Credential DumpingSysmon EID 10 (process access to LSASS)
T1486Data Encrypted for ImpactFile modification patterns, ransomware note creation

KQL (Kusto Query Language) Quick Reference

OperatorPurposeExample
whereFilter rowswhere EventID == 4625
summarizeAggregatesummarize count() by UserName
extendAdd columnsextend Hour = hourofday(TimeGenerated)
projectSelect columnsproject TimeGenerated, User, IP
joinCombine tables`T1
letDefine variableslet threshold = 10;
ago()Time relative to nowwhere TimeGenerated > ago(1h)
bin()Time bucketingbin(TimeGenerated, 5m)
dcount()Distinct countdcount(UserPrincipalName)
make_set()Collect unique valuesmake_set(IPAddress, 100)
has_anyContains any value from listwhere User has_any (admin_list)
serializeEnable row-order operatorsRequired before prev(), next()

SPL (Search Processing Language) Quick Reference

CommandPurposeExample
searchFilter eventsindex=main EventCode=4625
statsAggregatestats count by src_ip
evalCompute fieldseval hour=strftime(_time,"%H")
tableDisplay columnstable _time, user, src_ip
joinCombine searchesjoin type=inner user [search ...]
transactionGroup related eventstransaction user maxspan=30m
binTime bucketingbin _time span=5m
dc()Distinct countdc(user) as unique_users
values()Collect unique valuesvalues(src_ip) as source_ips
streamstatsRunning calculationsstreamstats window=1 last(field) as prev_field
iplocationGeoIP lookupiplocation ClientIP
lookupEnrich with lookup tablelookup threat_intel ip as src_ip

Shortened here. Read the whole file on GitHub.

Signals

GitHub stars
63
Forks
130
Last commit
Jun 2026
Advanced
Catalog kind
skill
Gateway key
siem-rules
Source
github.com/unitoneai/securityskills