Forum Discussion
Hunting AI Agent Configuration Drift with Microsoft Sentinel
Great work Marcel. The hash-based instruction comparison and the schema-tolerant IdentityInfo lookup are both smart calls, especially avoiding an overly broad ATT&CK mapping on the MCP server hunt.
One gap I noticed is that all four hunts rely on an inner join between current and baseline snapshots. That means a brand new agent created with an MCP server, an owner already assigned and org-wide sharing from the very first snapshot slips past all four queries since there is no prior state to compare against. Day-zero risk is different from drift and deserves its own hunt.
The second gap is that the four signals run independently. An agent that gets a new owner and expands to org-wide sharing within the same window is a much stronger lead than either signal alone, but nothing correlates them.
Here are two queries that build on your pattern to close both gaps.
Query 5, newly created agent with elevated capabilities from inception:
let lookback = 14d;
let recent = 2d;
let FirstSnapshot = AgentsInfo
| where Timestamp > ago(lookback)
| where LifecycleStatus != "Deleted"
| summarize arg_min(Timestamp, *) by AgentId;
FirstSnapshot
| where CreatedDateTime > ago(recent)
| extend McpServerCount = array_length(coalesce(McpServers, dynamic([])))
| extend DeclaredToolCount = array_length(coalesce(DeclaredTools, dynamic([])))
| extend OwnerCount = array_length(coalesce(Owners, dynamic([])))
| extend IsOrgWide = set_has_element(coalesce(SharedWith, dynamic([])), "*")
| where (McpServerCount > 0 or DeclaredToolCount > 0) and IsOrgWide
| project AgentId, Name, Platform, CreatedDateTime, McpServerCount, DeclaredToolCount, OwnerCount, IsOrgWide
| sort by CreatedDateTime descThis takes the earliest available snapshot per agent instead of comparing two states. If an agent is born already carrying MCP access and org-wide sharing there is no baseline to drift from, so this needs its own check rather than a diff.
Query 6, composite risk correlation across all four signals:
let lookback = 14d;
let recent = 2d;
let CurrentMcpNames = AgentsInfo
| where Timestamp > ago(recent)
| summarize arg_max(Timestamp, *) by AgentId
| where LifecycleStatus != "Deleted"
| mv-expand Mcp = McpServers
| extend McpName = tostring(Mcp.name)
| where isnotempty(McpName)
| summarize CurrentMcpServers = make_set(McpName) by AgentId;
let BaselineMcpNames = AgentsInfo
| where Timestamp between (ago(lookback) .. ago(recent))
| where LifecycleStatus != "Deleted"
| summarize arg_max(Timestamp, *) by AgentId
| mv-expand Mcp = McpServers
| extend McpName = tostring(Mcp.name)
| where isnotempty(McpName)
| summarize BaselineMcpServers = make_set(McpName) by AgentId;
let CurrentState = AgentsInfo
| where Timestamp > ago(recent)
| summarize arg_max(Timestamp, *) by AgentId
| where LifecycleStatus != "Deleted"
| project AgentId, Name, Platform, Timestamp,
CurrentInstructions = Instructions,
CurrentOwners = Owners,
CurrentSharedWith = SharedWith;
let BaselineState = AgentsInfo
| where Timestamp between (ago(lookback) .. ago(recent))
| where LifecycleStatus != "Deleted"
| summarize arg_max(Timestamp, *) by AgentId
| project AgentId,
PreviousInstructions = Instructions,
PreviousOwners = Owners,
BaselineSharedWith = SharedWith;
let Joined = CurrentState
| join kind=inner (BaselineState) on AgentId
| join kind=leftouter (CurrentMcpNames) on AgentId
| join kind=leftouter (BaselineMcpNames) on AgentId;
let InstructionsFlag = Joined
| where CurrentInstructions != PreviousInstructions
| project AgentId, Signal = "InstructionsChanged";
let McpFlag = Joined
| where array_length(set_difference(coalesce(CurrentMcpServers, dynamic([])), coalesce(BaselineMcpServers, dynamic([])))) > 0
| project AgentId, Signal = "NewMcpServer";
let OwnerFlag = Joined
| where array_length(set_difference(CurrentOwners, PreviousOwners)) > 0
| project AgentId, Signal = "OwnerAdded";
let SharingFlag = Joined
| where set_has_element(coalesce(CurrentSharedWith, dynamic([])), "*")
| where not(set_has_element(coalesce(BaselineSharedWith, dynamic([])), "*"))
| project AgentId, Signal = "SharingExpandedOrgWide";
union InstructionsFlag, McpFlag, OwnerFlag, SharingFlag
| summarize SignalCount = dcount(Signal), Signals = make_set(Signal) by AgentId
| where SignalCount >= 2
| join kind=inner (CurrentState) on AgentId
| project AgentId, Name, Platform, SignalCount, Signals, Timestamp
| sort by SignalCount descThis assumes the field aliases already used in your four scenarios like CurrentMcpServers, BaselineMcpServers, CurrentOwners, PreviousOwners and adds equivalents for SharedWith. Anyone reusing this should confirm the exact alias names against their own join since those get set during each scenario's projection step.
The core idea is that two or more of these signals landing on the same agent within the same 14 day window is a much stronger lead than any one of them in isolation, and it turns four separate hunts into one prioritized triage list.
Appreciate you sharing the original work and the PR process behind it.
Thanks, GoXATAKAN - really appreciate you taking the time to build on this.
You make a good point about day-zero risk being a separate use case from configuration drift. The inner join was intentional for these four hunts, but a dedicated query for agents that start with elevated capabilities would complement them well.
I also like the idea of correlating multiple changes into a prioritized triage view. For an operational version, I would probably build the normalized fields in a shared intermediate dataset or combine the finalized hunt outputs. I would also retain audit correlation for the exact timing, sequence, and actor attribution.
These are valuable additions to the pattern. Thanks again for sharing them!