Detection
Custom detection rules
Author your own sign-in detection rules without code changes — pick fields, operators, threshold, window, and group-by, then activate from the rules table.
Updated May 15, 2026 | 6 min
When to author a custom rule
- You have a SOC-specific detection pattern that the built-in catalog does not yet cover (e.g. failed sign-ins specifically to your ServiceNow SSO from outside the US).
- You want to tune sensitivity (threshold, window) for your tenant without waiting for a platform release.
- You want to run a hypothesis as a draft rule before promoting it to a built-in.
Predicate fields
These are the fields you can filter on. Adding more requires a platform code change.
status_error_code— Microsoft sign-in error code (e.g. 50126 = invalid credential, 50053 = account locked).app_id— The Azure AD application id the sign-in targeted.app_display_name— Human-readable app name (Microsoft Graph, Azure Portal, etc.).ip_address— Source IPv4 or IPv6 address.country— Country from the Microsoft Graph location enrichment.location_summary— Full location string ("City, State, Country").is_interactive— True for user-driven sign-ins, false for token-based (non-interactive).risk_level— Aggregated Microsoft sign-in risk (none, low, medium, high, hidden).risk_state— Microsoft risk state (atRisk, confirmedSafe, dismissed, remediated).user_principal_name— UPN of the signing-in identity (alice@contoso.com).client_app_used— Client application category (Browser, Mobile Apps and Desktop clients, IMAP, SMTP, etc.).browser— Browser name from deviceDetail (Chrome, Edge, Firefox, etc.).operating_system— OS from deviceDetail (Windows 11, MacOS, Linux, iOS, Android, etc.).
Operators
eq— Equals (case-insensitive for strings).ne— Not equals.in— Value is one of a comma-separated list — `country in RU, CN, IR`.not_in— Value is not in a comma-separated list — `country not_in US, CA, GB`.contains— Substring match — `app_display_name contains ServiceNow`.starts_with— Prefix match — `location_summary starts_with Moscow`.is_null— Field is missing or empty (no value to type).is_not_null— Field is present and non-empty.
Group-by keys
identity_id— Per-user threshold. Best for "5 failed signins for the SAME user in 10 minutes".ip_address— Per-source threshold. Best for credential stuffing — same IP across many users.app_id— Per-app threshold. Best for "20 failures targeting any single app".country— Per-country threshold. Best for geo-targeted bursts.
Example rules
ServiceNow SSO spray from outside the US
Filters (all must match):
app_display_name contains ServiceNowstatus_error_code eq 50126country not_in US, CA, GB
Threshold: 3 matches · Window: 10 minutes · Group by: identity_id
Credential stuffing on the same source IP
Filters (all must match):
status_error_code eq 50126
Threshold: 5 matches · Window: 15 minutes · Group by: ip_address
Risky sign-in to the Azure Portal
Filters (all must match):
app_display_name contains Azure Portalrisk_level in high, critical
Threshold: 1 match · Window: 60 minutes · Group by: identity_id
Legacy auth from outside trusted countries
Filters (all must match):
client_app_used in IMAP, SMTP, POP, Othercountry not_in US
Threshold: 1 match · Window: 60 minutes · Group by: identity_id
How to author a rule
- Open /detection-rules. The top card is "Author a custom sign-in detection".
- Fill in name, optional rule_type slug, and severity. Description is optional but recommended for context.
- Add up to 4 filter rows. All filters must match for an event to count (AND, not OR).
- Set threshold (matches needed) and window (minutes the matches must fall within).
- Pick a group-by key — this controls how matches are bucketed. Most rules group by identity_id.
- Click "Save as draft". The rule appears in the table below with status `draft` and does not fire.
- When you are ready, click "Activate" on the table row. The rule starts evaluating on the next detection cycle (every 60 seconds).
- Watch /alerts for rule firings. The alert title is the rule name; the rationale describes the match count and group key.
Safety guardrails
- Field and operator lists are whitelisted in the platform code. An author cannot reference arbitrary columns or run free-form SQL — the predicate is interpreted in Python against typed event objects.
- Threshold defaults to 1 with no maximum; window is in minutes (1 to 1440 = 24h is sensible).
- Rules with no filters are rejected at save time. At least one filter must be present.
- Custom rules respect the same NEW-status-only mutation guard as built-in rules: once the SOC triages an alert out of NEW, subsequent matches do not silently overwrite that alert.
- Custom rules go through the VIP escalation post-pass: alerts on VIP identities get severity bumped one tier and tagged with the VIP marker.
- Custom rule alerts do NOT yet carry MITRE ATT&CK chips by default. You can add them by editing the rule's `mitre_techniques` JSON column directly (UI tagging is a future enhancement).
Operational notes
- Drafts never fire. Activating a rule with a too-loose threshold can produce significant alert volume — start strict and loosen over time.
- Deactivate a noisy rule by clicking "Disable" in the table; the rule stays in the table but stops evaluating.
- Rule changes take effect on the next detection cycle (typically within 60 seconds).
- Each rule's firings use a stable source_id `custom_rule:{rule_id}:{group_key}:{hour_bucket}` so a single growing alert tracks the entire burst rather than creating dozens of duplicates.