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_codeMicrosoft sign-in error code (e.g. 50126 = invalid credential, 50053 = account locked).
  • app_idThe Azure AD application id the sign-in targeted.
  • app_display_nameHuman-readable app name (Microsoft Graph, Azure Portal, etc.).
  • ip_addressSource IPv4 or IPv6 address.
  • countryCountry from the Microsoft Graph location enrichment.
  • location_summaryFull location string ("City, State, Country").
  • is_interactiveTrue for user-driven sign-ins, false for token-based (non-interactive).
  • risk_levelAggregated Microsoft sign-in risk (none, low, medium, high, hidden).
  • risk_stateMicrosoft risk state (atRisk, confirmedSafe, dismissed, remediated).
  • user_principal_nameUPN of the signing-in identity (alice@contoso.com).
  • client_app_usedClient application category (Browser, Mobile Apps and Desktop clients, IMAP, SMTP, etc.).
  • browserBrowser name from deviceDetail (Chrome, Edge, Firefox, etc.).
  • operating_systemOS from deviceDetail (Windows 11, MacOS, Linux, iOS, Android, etc.).

Operators

  • eqEquals (case-insensitive for strings).
  • neNot equals.
  • inValue is one of a comma-separated list — `country in RU, CN, IR`.
  • not_inValue is not in a comma-separated list — `country not_in US, CA, GB`.
  • containsSubstring match — `app_display_name contains ServiceNow`.
  • starts_withPrefix match — `location_summary starts_with Moscow`.
  • is_nullField is missing or empty (no value to type).
  • is_not_nullField is present and non-empty.

Group-by keys

  • identity_idPer-user threshold. Best for "5 failed signins for the SAME user in 10 minutes".
  • ip_addressPer-source threshold. Best for credential stuffing — same IP across many users.
  • app_idPer-app threshold. Best for "20 failures targeting any single app".
  • countryPer-country threshold. Best for geo-targeted bursts.

Example rules

ServiceNow SSO spray from outside the US

Filters (all must match):

  • app_display_name contains ServiceNow
  • status_error_code eq 50126
  • country 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 Portal
  • risk_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, Other
  • country not_in US

Threshold: 1 match · Window: 60 minutes · Group by: identity_id

How to author a rule

  1. Open /detection-rules. The top card is "Author a custom sign-in detection".
  2. Fill in name, optional rule_type slug, and severity. Description is optional but recommended for context.
  3. Add up to 4 filter rows. All filters must match for an event to count (AND, not OR).
  4. Set threshold (matches needed) and window (minutes the matches must fall within).
  5. Pick a group-by key — this controls how matches are bucketed. Most rules group by identity_id.
  6. Click "Save as draft". The rule appears in the table below with status `draft` and does not fire.
  7. When you are ready, click "Activate" on the table row. The rule starts evaluating on the next detection cycle (every 60 seconds).
  8. 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.