{
  "technique_id": "T1078",
  "name": "Valid Accounts",
  "priority": "high",
  "status": "complete",
  "version": "0.1.0",
  "last_reviewed": "2026-07-23",
  "generated_by": "SOC Response Atlas by Basyrix",
  "tactics": [
    "Initial Access",
    "Persistence",
    "Privilege Escalation",
    "Stealth"
  ],
  "platforms": [
    "Windows",
    "Linux",
    "macOS",
    "Azure AD",
    "Office 365",
    "SaaS"
  ],
  "summary": "Adversaries may obtain and abuse credentials of existing accounts as a means of gaining initial access, persistence, privilege escalation, or defense evasion. Compromised credentials can bypass access controls and blend in with legitimate activity, making valid account abuse hard to detect with signature-based tooling alone.\n",
  "soc_recommendation": "Treat as potential account compromise. Validate sign-in activity, token usage, MFA changes, device posture, privilege changes, mailbox rules, OAuth grants, and lateral movement indicators before deciding whether this is expected user behaviour or an active intrusion.\n",
  "d3fend_mappings": [
    {
      "id": "D3-DAM",
      "name": "Domain Account Monitoring",
      "relationship": "detect",
      "practical_action": "Review domain/directory sign-in activity for the account -- risky sign-ins, impossible travel, unfamiliar devices, MFA prompts.\n",
      "tooling": [
        "Sentinel",
        "Entra ID",
        "Defender XDR"
      ]
    },
    {
      "id": "D3-LAM",
      "name": "Local Account Monitoring",
      "relationship": "detect",
      "practical_action": "Review local account logon activity on the affected host for use of the same credential outside the directory-managed path.\n",
      "tooling": [
        "Defender for Endpoint"
      ]
    },
    {
      "id": "D3-AL",
      "name": "Account Locking",
      "relationship": "evict",
      "practical_action": "Lock or disable the account once compromise is confirmed, and revoke its active sessions and refresh tokens.\n",
      "tooling": [
        "Microsoft Graph",
        "Entra ID"
      ]
    },
    {
      "id": "D3-UAP",
      "name": "User Account Permissions",
      "relationship": "isolate",
      "practical_action": "Review and, if needed, restrict the account's role assignments and group memberships to limit what a compromised session can reach.\n",
      "tooling": [
        "Entra ID",
        "AuditLogs"
      ]
    }
  ],
  "investigation_steps": {
    "microsoft": [
      "Check NoveltyReason — a new country carries more weight than a new user agent alone (browser updates and OS patches change the UA string routinely).",
      "Cross-reference IsTrustedVPNIP — if true, this is likely corporate VPN egress from a new range; verify against the TrustedVPNRanges watchlist and consider adding it if legitimate.",
      "Check BaselineSigninCount — a low count means the baseline itself is thin (new joiner) and false positives are more likely.",
      "Contact the user directly (not by email, in case the mailbox is compromised) — are they travelling, using a new device, or on a new ISP/VPN?",
      "Check HR travel records for business travel."
    ],
    "generic": [
      "Validate whether the authentication source is expected.",
      "Review recent successful and failed authentication events.",
      "Check privilege changes after authentication.",
      "Review access to sensitive systems.",
      "Real detection reference: \"MFA Fatigue / Push Bombing (MFA Spamming)\" -- Note: the Severity above is the ARM baseline (used when no success follows the burst). The query itself computes a per-row Severity/Verdict that escalates to Critical when the spam is followed by an eventual approval - see the Verdict/Severity logic below and tune the analytics rule's alert-severity mapping accordingly if you want Sentinel to reflect it. Detects \"MFA fatigue\" / push-bombing: an attacker who already holds a valid password repeatedly triggers MFA challenges for a user, hoping the user eventually taps Approve out of annoyance or confusion. The signature is a tight burst of DENIED or TIMED-OUT MFA challenges for the SAME user in quick succession - the source IP is whatever the attacker is using to replay the sign-in, not the victim's phone, so we correlate purely on UserPrincipalName rather than IP. Bursts are grouped by session-gap (row_cumsum), not a fixed tumbling bin: as long as consecutive denials/timeouts are <= sessionGapMin apart they stay in the same burst, so a spam run that starts at :58 and continues to :07 is still one incident instead of being split across two windows. Two outcomes are surfaced:   * Burst with NO subsequent approval        -> \"Attempted\" (Medium/High)   * Burst followed by an eventual MFA success within correlationWin     -> \"Succeeded\" (High/Critical) - the fatigue attack worked.     Extra weight is given when the successful sign-in's IP was never seen     in the burst itself (the user approved from their own device while the     attacker's sign-in attempts came from elsewhere - or the reverse: the     approval IS the attacker's own follow-up attempt succeeding). Scope: SigninLogs only (interactive sign-ins). Push/phone-call/passwordless MFA challenges are always interactive, so non-interactive token refreshes are out of scope for this rule by design."
    ]
  },
  "evidence_to_collect": [
    "User principal name",
    "Source IP",
    "Location",
    "Device ID",
    "User agent",
    "Application accessed",
    "Sign-in result",
    "MFA status",
    "Token activity",
    "Group or role changes",
    "Mailbox rule changes",
    "OAuth consent grants"
  ],
  "response_actions": [
    {
      "name": "Validate the sign-in with the user directly by phone",
      "category": "Containment",
      "risk": "Low",
      "automation_safe": false,
      "approval_required": true,
      "tool": "See investigation guide"
    },
    {
      "name": "If unconfirmed or suspicious: revoke active sessions and for",
      "category": "Containment",
      "risk": "Medium",
      "automation_safe": false,
      "approval_required": true,
      "tool": "See investigation guide",
      "notes": "If unconfirmed or suspicious: revoke active sessions and force password reset"
    },
    {
      "name": "Check for MFA method changes or new OAuth consents in the sa",
      "category": "Containment",
      "risk": "Low",
      "automation_safe": true,
      "approval_required": false,
      "tool": "See investigation guide",
      "notes": "Check for MFA method changes or new OAuth consents in the same session"
    },
    {
      "name": "Add legitimate new VPN ranges or corporate egress IPs to Tr",
      "category": "Eradication",
      "risk": "Low",
      "automation_safe": false,
      "approval_required": true,
      "tool": "See investigation guide",
      "notes": "Add legitimate new VPN ranges or corporate egress IPs to TrustedVPNRanges"
    },
    {
      "name": "Correlate with GEN-IA-016 (Impossible Travel) and GEN-IA-",
      "category": "Eradication",
      "risk": "Low",
      "automation_safe": false,
      "approval_required": true,
      "tool": "See investigation guide",
      "notes": "Correlate with GEN-IA-016 (Impossible Travel) and GEN-IA-020–GEN-IA-025 (brute force / spray family) for the same account"
    },
    {
      "name": "Document the outcome — confirmed travel/new device vs. confi",
      "category": "Eradication",
      "risk": "Low",
      "automation_safe": true,
      "approval_required": false,
      "tool": "See investigation guide",
      "notes": "Document the outcome — confirmed travel/new device vs. confirmed compromise"
    },
    {
      "name": "If compromised: follow full account-takeover recovery (sessi",
      "category": "Recovery",
      "risk": "Medium",
      "automation_safe": false,
      "approval_required": true,
      "tool": "See investigation guide",
      "notes": "If compromised: follow full account-takeover recovery (session revocation, password reset, MFA re-enrolment, OAuth consent audit)"
    }
  ],
  "queries": {
    "kql": [
      {
        "name": "GEN-CA-007 — MFA Fatigue / Push Bombing (MFA Spamming)",
        "description": "Note: the Severity above is the ARM baseline (used when no success follows the burst). The query itself computes a per-row Severity/Verdict that escalates to Critical when the spam is followed by an eventual approval - see the Verdict/Severity logic below and tune the analytics rule's alert-severity mapping accordingly if you want Sentinel to reflect it. Detects \"MFA fatigue\" / push-bombing: an attacker who already holds a valid password repeatedly triggers MFA challenges for a user, hoping the user eventually taps Approve out of annoyance or confusion. The signature is a tight burst of DENIED or TIMED-OUT MFA challenges for the SAME user in quick succession - the source IP is whatever the attacker is using to replay the sign-in, not the victim's phone, so we correlate purely on UserPrincipalName rather than IP. Bursts are grouped by session-gap (row_cumsum), not a fixed tumbling bin: as long as consecutive denials/timeouts are <= sessionGapMin apart they stay in the same burst, so a spam run that starts at :58 and continues to :07 is still one incident instead of being split across two windows. Two outcomes are surfaced:   * Burst with NO subsequent approval        -> \"Attempted\" (Medium/High)   * Burst followed by an eventual MFA success within correlationWin     -> \"Succeeded\" (High/Critical) - the fatigue attack worked.     Extra weight is given when the successful sign-in's IP was never seen     in the burst itself (the user approved from their own device while the     attacker's sign-in attempts came from elsewhere - or the reverse: the     approval IS the attacker's own follow-up attempt succeeding). Scope: SigninLogs only (interactive sign-ins). Push/phone-call/passwordless MFA challenges are always interactive, so non-interactive token refreshes are out of scope for this rule by design. (Source: Bell Integration baseline detection library, mapped via sub-technique T1078.004.)",
        "query": "let sessionGapMin   = 5;     // max gap (minutes) between consecutive denials/timeouts to stay in the same burst\nlet burstThreshold  = 4;     // minimum denied/timeout MFA challenges to call it a burst\nlet multiIPThreshold = 2;    // distinct source IPs within a burst that bumps severity even without a success\nlet correlationWin  = 30m;   // how long after the last denial an approval still counts as \"fatigue succeeded\"\nlet lookback        = 1d;\nlet deniedPhrases   = dynamic([\"denied\", \"declined\"]);\nlet timeoutPhrases  = dynamic([\"timeout\", \"timed out\", \"did not respond\", \"no response\"]);\nlet TrustedRanges =\n    _GetWatchlist(\"TrustedVPNRanges\")\n    | extend VPNRange = tostring(column_ifexists(\"SearchKey\", column_ifexists(\"Subnet\", \"\")))\n    | where isnotempty(VPNRange)\n    | extend VPNRange = trim(\" \", VPNRange)\n    | extend VPNRange = iff(VPNRange has \"/\", VPNRange, strcat(VPNRange, \"/32\"))\n    | summarize by VPNRange;\n// --- Per-user exclusions ---\n//   homeCountries : ISO 3166 alpha-2. Suppresses the alert only when EVERY IP\n//                   in the burst AND the eventual success (if any) resolve to\n//                   one of these countries - covers users with known flaky\n//                   connectivity who routinely fumble their own MFA prompt.\n// Currently EMPTY - no users excluded:\nlet userExclusions = dynamic([]);\nlet userHomeMap =\n    print arr = userExclusions\n    | mv-expand entry = arr\n    | where isnotempty(entry.homeCountries)\n    | project HomeUpn       = tolower(tostring(entry.upn)),\n              HomeCountries = todynamic(tolower(tostring(entry.homeCountries)));\nlet signinLogsSource =\n    union isfuzzy=true SigninLogs,\n    (datatable(TimeGenerated:datetime, UserPrincipalName:string, UserId:string, IPAddress:string, LocationDetails:dynamic, AppDisplayName:string, ResultType:string, ResultDescription:string, AuthenticationRequirement:string, AuthenticationDetails:dynamic, UserAgent:string, ClientAppUsed:string)[]);\nlet mfaSteps =\n    signinLogsSource\n    | where TimeGenerated >= ago(lookback)\n    | where isnotempty(UserPrincipalName) and isnotempty(AuthenticationDetails)\n    | extend UserPrincipalName = tolower(UserPrincipalName)\n    | mv-expand Step = AuthenticationDetails\n    | extend StepMethod       = tostring(Step.authenticationMethod),\n             StepSucceeded    = tobool(Step.succeeded),\n             StepResultDetail = tolower(tostring(Step.authenticationStepResultDetail))\n    | where isnotempty(StepResultDetail)\n    | evaluate ipv4_lookup(TrustedRanges, IPAddress, VPNRange, false)\n    | extend IsTrustedVPNIP = isnotempty(VPNRange)\n    | extend LocationDetailsDyn = todynamic(column_ifexists(\"LocationDetails\", dynamic({})))\n    | extend Location = tostring(LocationDetailsDyn.countryOrRegion)\n    | extend MfaOutcome = case(\n            StepSucceeded,                              \"Success\",\n            StepResultDetail has_any (deniedPhrases),    \"Denied\",\n            StepResultDetail has_any (timeoutPhrases),   \"Timeout\",\n            \"Other\")\n    | where MfaOutcome in (\"Denied\", \"Timeout\", \"Success\")\n    | project TimeGenerated, UserPrincipalName, UserId, IPAddress, Location, IsTrustedVPNIP,\n              AppDisplayName, ClientAppUsed, UserAgent, StepMethod, StepResultDetail, MfaOutcome;\n// --- Sessionize denials/timeouts per user by gap, not by fixed bin ---\nlet denials =\n    mfaSteps\n    | where MfaOutcome in (\"Denied\", \"Timeout\")\n    | order by UserPrincipalName asc, TimeGenerated asc\n    | extend PrevUser = prev(UserPrincipalName), PrevTime = prev(TimeGenerated)\n    | extend NewSession = iff(PrevUser != UserPrincipalName or isnull(PrevTime)\n                               or datetime_diff(\"minute\", TimeGenerated, PrevTime) > sessionGapMin, 1, 0)\n    | extend SessionId = row_cumsum(NewSession);\nlet bursts =\n    denials\n    | summarize\n            BurstStart      = min(TimeGenerated),\n            BurstEnd        = max(TimeGenerated),\n            DenialCount     = count(),\n            DeniedCount     = countif(MfaOutcome == \"Denied\"),\n            TimeoutCount    = countif(MfaOutcome == \"Timeout\"),\n            DistinctIPs     = dcount(IPAddress),\n            BurstIPs        = make_set(IPAddress, 32),\n            BurstLocations  = make_set(Location, 16),\n            BurstApps       = make_set(AppDisplayName, 16),\n            BurstMethods    = make_set(StepMethod, 8),\n            AnyTrustedVPNIP = max(IsTrustedVPNIP)\n        by UserPrincipalName, UserId, SessionId\n    | where DenialCount >= burstThreshold\n    | project-away SessionId;\nlet successes =\n    mfaSteps\n    | where MfaOutcome == \"Success\"\n    | project SuccessTime = TimeGenerated, UserPrincipalName, SuccessIP = IPAddress,\n              SuccessLocation = Location, SuccessIsTrustedVPNIP = IsTrustedVPNIP,\n              SuccessApp = AppDisplayName, SuccessClientApp = ClientAppUsed, SuccessUA = UserAgent;\n// Earliest qualifying approval AFTER the burst, within correlationWin. Computed\n// as its own scalar table (not a filtering join on bursts) so bursts with NO\n// matching success are never dropped - only enriched when one exists.\nlet burstSuccess =\n    bursts\n    | join kind=inner (successes) on UserPrincipalName\n    | where SuccessTime between (BurstEnd .. BurstEnd + correlationWin)\n    | summarize arg_min(SuccessTime, SuccessIP, SuccessLocation, SuccessIsTrustedVPNIP, SuccessApp, SuccessClientApp, SuccessUA)\n        by UserPrincipalName, BurstStart, BurstEnd;\nbursts\n| join kind=leftouter (burstSuccess) on UserPrincipalName, BurstStart, BurstEnd\n| extend FollowedBySuccess = isnotempty(SuccessTime)\n| extend SuccessFromNewIP  = FollowedBySuccess and not(set_has_element(BurstIPs, SuccessIP))\n| extend Verdict = case(\n        FollowedBySuccess and SuccessFromNewIP, \"MFA Fatigue - Succeeded (approval from new IP)\",\n        FollowedBySuccess,                      \"MFA Fatigue - Succeeded\",\n        DistinctIPs >= multiIPThreshold,        \"MFA Push Spam - Attempted (multi-IP)\",\n        \"MFA Push Spam - Attempted\")\n| extend Severity = case(\n        FollowedBySuccess and SuccessFromNewIP, \"Critical\",\n        FollowedBySuccess,                      \"High\",\n        DistinctIPs >= multiIPThreshold,         \"High\",\n        \"Medium\")\n// --- Apply per-user home-country exclusion ---\n// Drop only when the user is listed AND every burst location AND the success\n// location (if any) are all home countries.\n| join kind=leftouter (userHomeMap) on $left.UserPrincipalName == $right.HomeUpn\n| extend IsListed          = isnotempty(HomeUpn),\n         BurstLocationsLc   = todynamic(tolower(tostring(BurstLocations))),\n         SuccessLocationLc  = tolower(tostring(SuccessLocation))\n| extend AllBurstHome = IsListed\n                      and array_length(set_difference(coalesce(BurstLocationsLc, dynamic([])),\n                                                        coalesce(HomeCountries, dynamic([])))) == 0,\n         SuccessHome  = not(FollowedBySuccess)\n                      or (IsListed and set_has_element(HomeCountries, SuccessLocationLc))\n| where not(IsListed and AllBurstHome and SuccessHome)\n| project-away HomeUpn, HomeCountries, IsListed, BurstLocationsLc, SuccessLocationLc, AllBurstHome, SuccessHome\n| extend AccountName = tostring(split(UserPrincipalName, \"@\")[0]),\n         UPNSuffix   = tostring(split(UserPrincipalName, \"@\")[1])\n| project TimeGenerated = BurstEnd, UserPrincipalName, AccountName, UPNSuffix, UserId,\n          Verdict, Severity, BurstStart, BurstEnd, DenialCount, DeniedCount, TimeoutCount,\n          DistinctIPs, BurstIPs, BurstLocations, BurstApps, BurstMethods, AnyTrustedVPNIP,\n          FollowedBySuccess, SuccessTime, SuccessIP, SuccessFromNewIP, SuccessLocation,\n          SuccessIsTrustedVPNIP, SuccessApp, SuccessClientApp, SuccessUA\n| extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName,\n         IPCustomEntity = iff(FollowedBySuccess, SuccessIP, tostring(BurstIPs[0]))\n| order by Severity asc, DenialCount desc, TimeGenerated desc"
      },
      {
        "name": "GEN-IA-005 — Phishing Click Followed by Account Change",
        "description": "Detects a user clicking a suspicious/phishing URL followed shortly by account changes often seen after credential capture, such as MFA method changes, password reset activity, or risky consent/registration events. (Source: Bell Integration baseline detection library.)",
        "query": "let lookback = 1h;\nlet correlationWindow = 45m;\nlet suspiciousAccountOperations = dynamic([\n    \"User registered security info\",\n    \"User changed default security info\",\n    \"User deleted security info\",\n    \"Admin registered security info\",\n    \"Reset password (self-service)\",\n    \"Reset user password\",\n    \"Change user password\",\n    \"Consent to application\",\n    \"Add app role assignment to service principal\"\n]);\nlet urlClickEventsSource =\n    union isfuzzy=true UrlClickEvents,\n    (datatable(TimeGenerated:datetime, Url:string, AccountUpn:string, ThreatTypes:string, IsClickedThrough:string, ActionType:string, NetworkMessageId:string, IPAddress:string)[]);\nlet auditLogsSource =\n    union isfuzzy=true AuditLogs,\n    (datatable(TimeGenerated:datetime, OperationName:string, InitiatedBy:dynamic, TargetResources:dynamic, Result:string, AdditionalDetails:dynamic)[]);\nlet suspiciousClicks =\n    urlClickEventsSource\n    | where TimeGenerated >= ago(lookback)\n    | extend AccountUpn = tolower(tostring(column_ifexists(\"AccountUpn\", \"\"))),\n             Url = tostring(column_ifexists(\"Url\", \"\")),\n             ThreatTypes = tostring(column_ifexists(\"ThreatTypes\", \"\")),\n             IsClickedThrough = tolower(tostring(column_ifexists(\"IsClickedThrough\", \"false\"))),\n             ActionType = tostring(column_ifexists(\"ActionType\", \"\")),\n             ClickIP = tostring(column_ifexists(\"IPAddress\", \"\"))\n    | where isnotempty(AccountUpn) and isnotempty(Url)\n    | where ThreatTypes has \"Phish\" or IsClickedThrough == \"true\" or ActionType in~ (\"ClickAllowed\", \"ClickThrough\")\n    | extend ParsedUrl = parse_url(Url),\n             UrlHost = tolower(tostring(ParsedUrl.Host))\n    | project ClickTime = TimeGenerated, AccountUpn, Url, UrlHost, ThreatTypes, ActionType, IsClickedThrough, ClickIP, NetworkMessageId;\nlet accountChanges =\n    auditLogsSource\n    | where TimeGenerated >= ago(lookback)\n    | where OperationName has_any (suspiciousAccountOperations)\n    | extend Actor = tolower(tostring(InitiatedBy.user.userPrincipalName)),\n             TargetUser = tolower(tostring(TargetResources[0].userPrincipalName)),\n             TargetDisplayName = tostring(TargetResources[0].displayName),\n             ChangeDetails = tostring(AdditionalDetails)\n    | extend CorrelationUser = coalesce(Actor, TargetUser)\n    | where isnotempty(CorrelationUser)\n    | project ChangeTime = TimeGenerated, CorrelationUser, Actor, TargetUser, TargetDisplayName, OperationName, Result, ChangeDetails;\nsuspiciousClicks\n| join kind=inner accountChanges on $left.AccountUpn == $right.CorrelationUser\n| where ChangeTime between (ClickTime .. ClickTime + correlationWindow)\n| project\n    TimeGenerated = ChangeTime,\n    AccountUpn,\n    ClickTime,\n    ChangeTime,\n    Url,\n    UrlHost,\n    ThreatTypes,\n    ActionType,\n    ClickIP,\n    OperationName,\n    Actor,\n    TargetUser,\n    TargetDisplayName,\n    Result,\n    ChangeDetails,\n    NetworkMessageId\n| extend AlertTitle = strcat(\"Phishing click followed by account change: \", AccountUpn),\n         timestamp = TimeGenerated,\n         AccountCustomEntity = AccountUpn,\n         IPCustomEntity = ClickIP,\n         URLCustomEntity = Url\n| order by TimeGenerated desc"
      },
      {
        "name": "GEN-IA-016 — Valid Accounts - Impossible Travel",
        "description": "Detects successful sign-ins from the same account where consecutive locations imply impossible travel. The retired IA-001 distance guard and application, conditional access, user-agent, and TrustedVPNRanges context are folded in here. --- Per-user exclusions (same shape as IA-020/022/023/024) ---   homeCountries : ISO 3166 alpha-2. Suppress the alert when BOTH endpoints of                   the \"impossible\" transition are declared home countries for                   this UPN (e.g. user bounces between two corporate VPN egress                   countries faster than physically possible - not real travel).   noisyApps     : exact AppDisplayName strings. Drop this UPN's sign-in events                   under those apps BEFORE pairing, so background token-refresh /                   broker apps that hop edge IPs don't manufacture false pairs. Example (commented out - uncomment and edit when needed):   let userExclusions = dynamic([       {\"upn\":\"jjanga@bell-integration.com\",        \"homeCountries\":[\"IN\",\"GB\"],        \"noisyApps\":[\"Microsoft Authentication Broker\",\"Windows Sign In\"]}   ]); (Source: Bell Integration baseline detection library.)",
        "query": "let userExclusions = dynamic([]);\nlet userHomeMap =\n    print arr = userExclusions\n    | mv-expand entry = arr\n    | where isnotempty(entry.homeCountries)\n    | project HomeUpn       = tolower(tostring(entry.upn)),\n              HomeCountries = todynamic(tolower(tostring(entry.homeCountries)));\nlet userNoisyAppsMap =\n    print arr = userExclusions\n    | mv-expand entry = arr\n    | where isnotempty(entry.noisyApps)\n    | project NoisyUpn  = tolower(tostring(entry.upn)),\n              NoisyApps = todynamic(tolower(tostring(entry.noisyApps)));\n//\nlet lookback = 1d;\nlet maxSpeedKmh = 900.0;\nlet minDistanceKm = 100.0;\nlet trustedRanges =\n    _GetWatchlist(\"TrustedVPNRanges\")\n    | extend VPNRange = tostring(column_ifexists(\"SearchKey\", column_ifexists(\"Subnet\", \"\")))\n    | where isnotempty(VPNRange)\n    | extend VPNRange = trim(\" \", VPNRange)\n    | extend VPNRange = iff(VPNRange has \"/\", VPNRange, strcat(VPNRange, \"/32\"))\n    | summarize by VPNRange;\nlet signinLogsSource =\n    union isfuzzy=true SigninLogs,\n    (datatable(TimeGenerated:datetime, ResultType:string, IPAddress:string, LocationDetails:dynamic, DeviceDetail:dynamic, UserPrincipalName:string, AppDisplayName:string, ConditionalAccessStatus:string, UserAgent:string)[]);\nsigninLogsSource\n| where TimeGenerated >= ago(lookback)\n| where tostring(ResultType) == \"0\"\n| where isnotempty(UserPrincipalName) and isnotempty(IPAddress)\n| extend LocationDetailsDyn = todynamic(LocationDetails),\n         DeviceDetailDyn = todynamic(DeviceDetail)\n| extend Lat = todouble(LocationDetailsDyn.geoCoordinates.latitude),\n         Lon = todouble(LocationDetailsDyn.geoCoordinates.longitude),\n         City = tostring(LocationDetailsDyn.city),\n         Country = tostring(LocationDetailsDyn.countryOrRegion),\n         DeviceName = tostring(DeviceDetailDyn.displayName),\n         DeviceId = tostring(DeviceDetailDyn.deviceId),\n         Browser = tostring(DeviceDetailDyn.browser),\n         OperatingSystem = tostring(DeviceDetailDyn.operatingSystem),\n         UserPrincipalName = tolower(UserPrincipalName)\n| evaluate ipv4_lookup(trustedRanges, IPAddress, VPNRange, false)\n| extend IsTrustedVPNIP = isnotempty(VPNRange)\n| where isnotempty(Lat) and isnotempty(Lon)\n// Drop sign-in events for apps the user has declared noisy, so background\n// token-refresh / broker traffic can't manufacture a false \"impossible\" pair.\n| join kind=leftouter (userNoisyAppsMap) on $left.UserPrincipalName == $right.NoisyUpn\n| where isempty(NoisyUpn)\n     or not(set_has_element(coalesce(NoisyApps, dynamic([])), tolower(tostring(AppDisplayName))))\n| project-away NoisyUpn, NoisyApps\n| sort by UserPrincipalName asc, TimeGenerated asc\n| serialize\n| extend PrevTime = prev(TimeGenerated),\n         PrevUser = prev(UserPrincipalName),\n         PrevIP = prev(IPAddress),\n         PrevLat = prev(Lat),\n         PrevLon = prev(Lon),\n         PrevCity = prev(City),\n         PrevCountry = prev(Country),\n         PrevDeviceName = prev(DeviceName),\n         PrevDeviceId = prev(DeviceId),\n         PrevAppDisplayName = prev(AppDisplayName),\n         PrevConditionalAccessStatus = prev(ConditionalAccessStatus),\n         PrevUserAgent = prev(UserAgent),\n         PrevIsTrustedVPNIP = prev(IsTrustedVPNIP),\n         PrevVPNRange = prev(VPNRange)\n| where UserPrincipalName == PrevUser\n| where IPAddress != PrevIP\n| where isnotempty(PrevLat) and isnotempty(PrevLon)\n| extend HoursBetween = abs(datetime_diff(\"second\", TimeGenerated, PrevTime)) / 3600.0\n| where HoursBetween > 0\n| extend DistanceKm = 6371.0 * 2 * asin(sqrt(\n    pow(sin((Lat - PrevLat) * pi() / 180.0 / 2.0), 2.0) +\n    cos(PrevLat * pi() / 180.0) * cos(Lat * pi() / 180.0) *\n    pow(sin((Lon - PrevLon) * pi() / 180.0 / 2.0), 2.0)\n))\n| extend SpeedKmh = DistanceKm / HoursBetween\n| where SpeedKmh > maxSpeedKmh and DistanceKm > minDistanceKm\n| project\n    TimeGenerated,\n    UserPrincipalName,\n    FromTime = PrevTime,\n    FromIP = PrevIP,\n    FromCity = PrevCity,\n    FromCountry = PrevCountry,\n    FromDeviceName = PrevDeviceName,\n    FromDeviceId = PrevDeviceId,\n    FromAppDisplayName = PrevAppDisplayName,\n    FromConditionalAccessStatus = PrevConditionalAccessStatus,\n    FromUserAgent = PrevUserAgent,\n    FromIsTrustedVPNIP = PrevIsTrustedVPNIP,\n    FromVPNRange = PrevVPNRange,\n    ToIP = IPAddress,\n    ToCity = City,\n    ToCountry = Country,\n    ToDeviceName = DeviceName,\n    ToDeviceId = DeviceId,\n    ToAppDisplayName = AppDisplayName,\n    ToConditionalAccessStatus = ConditionalAccessStatus,\n    ToUserAgent = UserAgent,\n    ToIsTrustedVPNIP = IsTrustedVPNIP,\n    ToVPNRange = VPNRange,\n    TrustedVPNInPath = (coalesce(PrevIsTrustedVPNIP, false) or coalesce(IsTrustedVPNIP, false)),\n    DistanceKm = round(DistanceKm, 1),\n    HoursBetween = round(HoursBetween, 3),\n    SpeedKmh = round(SpeedKmh, 1)\n// Suppress if BOTH the origin and destination country are on this user's home\n// country allow list (e.g. rapid bounce between two declared corporate VPN\n// egress countries - not genuine impossible travel).\n| join kind=leftouter (userHomeMap) on $left.UserPrincipalName == $right.HomeUpn\n| extend FromCountryLc = tolower(FromCountry), ToCountryLc = tolower(ToCountry)\n| extend IsHomeCountryPair = isnotempty(HomeUpn)\n                           and set_has_element(coalesce(HomeCountries, dynamic([])), FromCountryLc)\n                           and set_has_element(coalesce(HomeCountries, dynamic([])), ToCountryLc)\n| where not(IsHomeCountryPair)\n| project-away HomeUpn, HomeCountries, FromCountryLc, ToCountryLc, IsHomeCountryPair\n| extend AccountCustomEntity = UserPrincipalName,\n         IPCustomEntity = ToIP,\n         HostCustomEntity = ToDeviceName,\n         timestamp = TimeGenerated\n| order by SpeedKmh desc"
      }
    ],
    "spl": [
      {
        "name": "Recent authentication events",
        "description": "Review recent authentication events in Splunk.",
        "query": "index=authentication user=\"<user>\" earliest=-24h\n"
      }
    ],
    "esql": [
      {
        "name": "f0cc239b-67fa-46fc-89d4-f861753a40f5 — M365 or Entra ID Identity Sign-in from a Suspicious Source",
        "description": "(ESQL) This rule correlate Entra-ID or Microsoft 365 mail successful sign-in events with network security alerts by source address. Adversaries may trigger some network security alerts such as reputation or other anomalies before accessing cloud resources. (Source: Elastic's official detection-rules repository (Elastic License v2).)",
        "query": "from logs-o365.audit-*, logs-azure.signinlogs-*, .alerts-security.*\n// filter for azure or m365 sign-in and external alerts with source.ip not null\n| where to_ip(source.ip) is not null\n  and (data_stream.dataset in (\"o365.audit\", \"azure.signinlogs\") or kibana.alert.rule.rule_id == \"eb079c62-4481-4d6e-9643-3ca499df7aaa\")\n  and not cidr_match(\n    to_ip(source.ip),\n    \"10.0.0.0/8\", \"127.0.0.0/8\", \"169.254.0.0/16\", \"172.16.0.0/12\", \"192.0.0.0/24\", \"192.0.0.0/29\",\n    \"192.0.0.8/32\", \"192.0.0.9/32\", \"192.0.0.10/32\", \"192.0.0.170/32\", \"192.0.0.171/32\", \"192.0.2.0/24\",\n    \"192.31.196.0/24\", \"192.52.193.0/24\", \"192.168.0.0/16\", \"192.88.99.0/24\", \"224.0.0.0/4\",\n    \"100.64.0.0/10\", \"192.175.48.0/24\", \"198.18.0.0/15\", \"198.51.100.0/24\", \"203.0.113.0/24\",\n    \"240.0.0.0/4\", \"::1\", \"FE80::/10\", \"FF00::/8\"\n  )\n\n// capture relevant raw fields\n| keep source.ip, event.action, event.outcome, data_stream.dataset, kibana.alert.rule.rule_id, event.category\n\n// classify each source ip based on alert type\n| eval\n  Esql.source_ip_mail_access_case = case(data_stream.dataset == \"o365.audit\" and event.action == \"MailItemsAccessed\" and event.outcome == \"success\", to_ip(source.ip)),\n  Esql.source_ip_azure_signin_case = case(data_stream.dataset == \"azure.signinlogs\" and event.outcome == \"success\", to_ip(source.ip)),\n  Esql.source_ip_network_alert_case = case(kibana.alert.rule.rule_id == \"eb079c62-4481-4d6e-9643-3ca499df7aaa\" and not data_stream.dataset in (\"o365.audit\", \"azure.signinlogs\"), to_ip(source.ip))\n\n// aggregate by source ip\n| stats\n    Esql.event_count = count(*),\n    Esql.source_ip_mail_access_case_count_distinct = count_distinct(Esql.source_ip_mail_access_case),\n    Esql.source_ip_azure_signin_case_count_distinct = count_distinct(Esql.source_ip_azure_signin_case),\n    Esql.source_ip_network_alert_case_count_distinct = count_distinct(Esql.source_ip_network_alert_case),\n    Esql.data_stream_dataset_count_distinct = count_distinct(data_stream.dataset),\n    Esql.data_stream_dataset_values = values(data_stream.dataset),\n    Esql.kibana_alert_rule_id_values = values(kibana.alert.rule.rule_id),\n    Esql.event_category_values = values(event.category)\n  by Esql.source_ip = to_ip(source.ip)\n\n// correlation condition\n| where\n  Esql.source_ip_network_alert_case_count_distinct > 0\n  and Esql.data_stream_dataset_count_distinct >= 2\n  and (Esql.source_ip_mail_access_case_count_distinct > 0 or Esql.source_ip_azure_signin_case_count_distinct > 0)\n  and Esql.event_count <= 100"
      },
      {
        "name": "98cfaa44-83f0-4aba-90c4-363fb9d51a75 — AWS IAM Long-Term Access Key Correlated with Elevated Detection Alerts",
        "description": "(ESQL) Correlates open detection alerts that share the same long-term IAM access key ID ( prefix AKIA). It fires when the rule AWS Long-Term Access Key First Seen from Source IP (rule_id: 9f8e3c5e-f72e-4e91-93f6-e98a4fae3e4f) has triggered for that key and at least one other open alert for the same key is medium, high, or critical severity... (Source: Elastic's official detection-rules repository (Elastic License v2).)",
        "query": "from .alerts-security.* METADATA _id, _version, _index\n\n// Sibling rule: AWS Long-Term Access Key First Seen from Source IP\n// rule_id = 9f8e3c5e-f72e-4e91-93f6-e98a4fae3e4f\n\n| where kibana.alert.workflow_status == \"open\"\n    and event.kind == \"signal\"\n    and source.ip is not null\n    and kibana.alert.rule.name is not null\n    and not kibana.alert.rule.type in (\"threat_match\", \"machine_learning\")\n    and not kibana.alert.rule.name like \"Deprecated - *\"\n    and not KQL(\"\"\"kibana.alert.rule.tags : \"Rule Type: Higher-Order Rule\" \"\"\")\n    and (\n        kibana.alert.rule.rule_id == \"9f8e3c5e-f72e-4e91-93f6-e98a4fae3e4f\"\n        or kibana.alert.risk_score >= 47\n        or kibana.alert.severity in (\"medium\", \"high\", \"critical\")\n    )\n\n| eval Esql.is_long_term_key_new_ip_rule = kibana.alert.rule.rule_id == \"9f8e3c5e-f72e-4e91-93f6-e98a4fae3e4f\"\n| eval Esql.is_other_elevated_rule = kibana.alert.rule.rule_id != \"9f8e3c5e-f72e-4e91-93f6-e98a4fae3e4f\"\n    and (\n        kibana.alert.risk_score >= 47\n        or kibana.alert.severity in (\"medium\", \"high\", \"critical\")\n    )\n\n| stats\n    Esql.alert_count_long_term_key_new_ip_rule = SUM(CASE(Esql.is_long_term_key_new_ip_rule, 1, 0)),\n    Esql.alert_count_other_elevated_rule = SUM(CASE(Esql.is_other_elevated_rule, 1, 0)),\n    Esql.kibana_alert_rule_name_values = VALUES(kibana.alert.rule.name),\n    Esql.kibana_alert_rule_id_values = VALUES(kibana.alert.rule.rule_id),\n    Esql.kibana_alert_risk_score_values = VALUES(kibana.alert.risk_score),\n    Esql.kibana_alert_severity_values = VALUES(kibana.alert.severity),\n    Esql.user_entity_id_values = VALUES(user.entity.id),\n    Esql.timestamp_min = MIN(@timestamp),\n    Esql.timestamp_max = MAX(@timestamp)\n  by source.ip\n\n| where Esql.alert_count_long_term_key_new_ip_rule > 0\n    and Esql.alert_count_other_elevated_rule > 0\n\n| keep\n    source.ip,\n    Esql.alert_count_long_term_key_new_ip_rule,\n    Esql.alert_count_other_elevated_rule,\n    Esql.kibana_alert_rule_name_values,\n    Esql.kibana_alert_rule_id_values,\n    Esql.kibana_alert_risk_score_values,\n    Esql.kibana_alert_severity_values,\n    Esql.user_entity_id_values,\n    Esql.timestamp_min,\n    Esql.timestamp_max"
      }
    ]
  },
  "automation": {
    "safe": [
      "Add recommendation as Sentinel incident comment.",
      "Create ServiceNow SecOps task.",
      "Run enrichment queries.",
      "Add suspicious IP to watchlist for review."
    ],
    "approval_required": [
      "Disable account.",
      "Reset password.",
      "Revoke sessions for privileged users.",
      "Block IP tenant-wide."
    ]
  },
  "escalation_criteria": [
    "Privileged account involved.",
    "Break-glass account involved.",
    "Successful sign-in from unfamiliar country.",
    "MFA method was changed.",
    "OAuth grant was added.",
    "Mailbox forwarding rule was created.",
    "Access to sensitive data occurred after sign-in.",
    "Service principal or managed identity involved."
  ],
  "false_positive_considerations": [
    "Legitimate business travel — User confirms travel; HR booking records match",
    "New corporate device rollout — Device matches Intune/MDM enrolment records for that user around the same time",
    "Browser or OS upgrade — Only NewUserAgent fires, country and device unchanged",
    "New VPN egress range — IsTrustedVPNIP false but IP resolves to known corporate VPN vendor ASN"
  ],
  "confluence": {
    "title": "T1078 - Valid Accounts Response Guidance",
    "labels": [
      "mitre",
      "attack",
      "d3fend",
      "secops",
      "basyrix"
    ],
    "sections": [
      "summary",
      "d3fend_mappings",
      "investigation_steps",
      "response_actions",
      "queries",
      "automation",
      "escalation_criteria",
      "false_positive_considerations"
    ]
  }
}