Detection rules › Kusto

Failed Logins from Unknown or Invalid User

Status
available
Severity
medium
Time window
1h
Group by
actor_alternateId_s, client_ipAddress_s
Source
github.com/Azure/Azure-Sentinel

This rule alerts when an unknown or invalid user generates more than 15 failed Okta management console login attempts from the same IP address within 5 minutes.

MITRE ATT&CK coverage

TacticTechniques
Credential Access

Telemetry coverage

Rules detecting the same action

These rules filter on the same operation.

Rule body

id: 884be6e7-e568-418e-9c12-89229865ffde
name: Failed Logins from Unknown or Invalid User
description: |
  This rule alerts when an unknown or invalid user generates more than 15 failed Okta management console login attempts from the same IP address within 5 minutes.
severity: Medium
status: Available
requiredDataConnectors:
  - connectorId: OktaSSO
    dataTypes:
      - Okta_CL
  - connectorId: OktaSSOv2
    dataTypes:
      - OktaSSO
queryFrequency: 1h
queryPeriod: 1h
triggerOperator: gt
triggerThreshold: 0
tactics:
  - CredentialAccess
relevantTechniques:
  - T1110.001
query: |
  // Adjust threshold for failed logins to reduce noise. This can be customized based on the organization's typical login patterns.
  let FailureThreshold = 15;
  // Adjust list of allowed users to reduce false positives. This can include service accounts, known test accounts, or any other accounts that are expected to generate failed login attempts.
  let AllowedUsers = dynamic([]);
  let FailedLogins = OktaSSO
  | where eventType_s =~ "user.session.start" and outcome_reason_s =~ "VERIFICATION_ERROR"
  | where isnotempty(actor_alternateId_s) and actor_alternateId_s !in (AllowedUsers)
  | summarize count() by actor_alternateId_s, client_ipAddress_s, bin(TimeGenerated, 5m)
  | where count_ > FailureThreshold
  | project client_ipAddress_s, actor_alternateId_s;
  OktaSSO
  | join kind=inner (FailedLogins) on client_ipAddress_s, actor_alternateId_s
  | where eventType_s =~ "user.session.start" and outcome_reason_s =~ "VERIFICATION_ERROR"
  | summarize FailedLoginCount = count(), City = take_any(column_ifexists('client_geographicalContext_city_s', "")), Country = take_any(column_ifexists('client_geographicalContext_country_s', "")), LastSeen = max(column_ifexists('TimeGenerated', now())) by actor_alternateId_s, client_ipAddress_s
  | sort by LastSeen desc
  | extend AccountName = tostring(split(actor_alternateId_s, "@")[0]), AccountUPNSuffix = tostring(split(actor_alternateId_s, "@")[1])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: AccountUPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: client_ipAddress_s
customDetails:
  FailedLoginCount: FailedLoginCount
  City: City
  Country: Country
alertDetailsOverride:
  alertDisplayNameFormat: 'Failed logins from unknown or invalid user: {{actor_alternateId_s}} from IP {{client_ipAddress_s}}'
  alertDescriptionFormat: 'Account {{actor_alternateId_s}} generated {{FailedLoginCount}} failed logins from IP {{client_ipAddress_s}} in the last 5 minutes.'
version: 1.1.2
kind: Scheduled

Stages and Predicates

Parameters

let FailureThreshold = 15;
let AllowedUsers = dynamic([]);

Let binding: FailedLogins used in Stage 2

let FailedLogins = OktaSSO
| where eventType_s =~ "user.session.start" and outcome_reason_s =~ "VERIFICATION_ERROR"
| where isnotempty(actor_alternateId_s) and actor_alternateId_s !in (AllowedUsers)
| summarize count() by actor_alternateId_s, client_ipAddress_s, bin(TimeGenerated, 5m)
| where count_ > FailureThreshold
| project client_ipAddress_s, actor_alternateId_s;

Stage 1: source

OktaSSO

Stage 2: join

| join kind=inner (FailedLogins) on client_ipAddress_s, actor_alternateId_s

Stage 3: where

| where eventType_s =~ "user.session.start" and outcome_reason_s =~ "VERIFICATION_ERROR"

Stage 4: summarize

| summarize FailedLoginCount = count(), City = take_any(column_ifexists('client_geographicalContext_city_s', "")), Country = take_any(column_ifexists('client_geographicalContext_country_s', "")), LastSeen = max(column_ifexists('TimeGenerated', now())) by actor_alternateId_s, client_ipAddress_s

Stage 5: sort

| sort by LastSeen desc

Stage 6: extend

| extend AccountName = tostring(split(actor_alternateId_s, "@")[0]), AccountUPNSuffix = tostring(split(actor_alternateId_s, "@")[1])

Exclusions

The rule actively suppresses these predicates.

FieldKindExcluded valuesSearch
actor_alternateId_seq[]excludes:actor_alternateId_s field:"actor_alternateId_s" value:"[]"

Indicators

These rows show field, operator, and value matches.

Output fields

These fields are emitted when the rule matches.

FieldSource
Citysummarize
Countrysummarize
FailedLoginCountsummarize
LastSeensummarize
actor_alternateId_ssummarize
client_ipAddress_ssummarize
AccountNameextend
AccountUPNSuffixextend