Skip to content
Notifications
Clear all

Just built a detection for anomalous service principal sign-ins - sharing KQL

1 Posts
1 Users
0 Reactions
4 Views
(@chrisd)
Estimable Member
Joined: 1 week ago
Posts: 91
Topic starter   [#17127]

Hey folks, been diving deep into our Sentinel logs lately, and one area that always gives me pause is service principal activity. While we have solid detections for user account anomalies, the non-human identities can sometimes fly under the radar. I wanted to share a detection rule I've been tuning that looks for anomalous sign-in patterns for service principals, specifically focusing on sudden changes in location and application context.

The core idea is to baseline a service principal's typical sign-in behavior over a lookback period (say, 7 days) and then flag recent sign-ins that deviate significantly. The key indicators I'm focusing on are:
- **Unfamiliar Location**: A sign-in from a country the service principal hasn't been seen in before.
- **New Calling Application**: A sign-in from a client app ID that hasn't been used by this principal in the baseline period.

Here's the KQL query that forms the heart of the rule. It's built to run in Azure Sentinel's scheduled rule engine.

```kql
let lookback = 7d;
let baselinePeriod = 14d; // We analyze the 7 days before the last 7 days to establish a stable baseline.
let recentPeriod = 1d; // The timeframe for detections.
// Gather all service principal sign-ins from the baseline period.
let baselineLogs = SigninLogs
| where TimeGenerated between (ago(baselinePeriod)..ago(lookback))
| where Identity is not empty and isempty(UserPrincipalName) and not(isempty(AppId))
| where ResultType == 0
| summarize by ServicePrincipalId, Country, ClientAppUsed;
// Gather recent service principal sign-ins.
let recentLogs = SigninLogs
| where TimeGenerated >= ago(recentPeriod)
| where Identity is not empty and isempty(UserPrincipalName) and not(isempty(AppId))
| where ResultType == 0
| project TimeGenerated, ServicePrincipalId, Country, City, IPAddress, ClientAppUsed, ResourceDisplayName, ConditionalAccessStatus;
// Compare recent logs against the baseline to find anomalies.
recentLogs
| join kind=leftanti baselineLogs on ServicePrincipalId, Country, ClientAppUsed
| summarize StartTime = min(TimeGenerated), EndTime = max(TimeGenerated), AnomalousCountries = make_set(Country), AnomalousClientApps = make_set(ClientAppUsed) by ServicePrincipalId, IPAddress, City, ResourceDisplayName, ConditionalAccessStatus
| extend AnomalyCount = array_length(AnomalousCountries) + array_length(AnomalousClientApps)
| where AnomalyCount > 0
```

**A few important trade-offs and notes on this approach:**

* This uses a `leftanti` join to find recent entries *not* present in the baseline. It's efficient but means any new service principal with no baseline will be flagged immediately. You might want to handle brand-new principals separately.
* The baseline is static for the query's execution. In production, you'd likely want to implement a rolling, dynamic baseline, which is more complex but more accurate.
* We're only considering successful sign-ins (`ResultType == 0`). You might also want to incorporate failed attempts for a more comprehensive threat picture.
* Consider adding thresholds (e.g., flag only if more than 2 anomalies occur) to reduce noise for particularly volatile service principals.

This query gives you a starting point. From here, you can extend it to look at other properties like IP address ranges (using the `geo_ip_lookup` function), unusual resource access, or time-of-day patterns. I've found layering these signals significantly improves fidelity.

Would love to hear how others are tackling this. Have you found other high-signal indicators for service principal anomalies? Maybe integrating this with Graph API to pull in application metadata? Let's discuss below.

—Chris


Prod is the only environment that matters.


   
Quote