AI Agents - Owner added to MCP-enabled agent


Description

Identifies owners newly observed on existing AI agents with MCP servers configured. Review the added owners and audit records to confirm whether privileged access was authorized. Run within 2 days of a change to retain coverage.

Query · kql

let lookback = 14d;
let recent = 2d;
let IdentityIdtoUPN = materialize(
    IdentityInfo
    | extend ResolvedAccountUpn = tostring(column_ifexists("AccountUpn", column_ifexists("AccountUPN", ""))),
             IdentityTimestamp = todatetime(column_ifexists("Timestamp", column_ifexists("TimeGenerated", datetime(null))))
    | where IdentityTimestamp >= ago(lookback)
    | where isnotempty(AccountObjectId) and isnotempty(ResolvedAccountUpn)
    | summarize arg_max(IdentityTimestamp, ResolvedAccountUpn) by AccountObjectId
    | project AccountObjectId = tostring(AccountObjectId), AccountUpn = ResolvedAccountUpn);
let CurrentState = materialize(
    AgentsInfo
    | where Timestamp > ago(recent)
    | summarize arg_max(Timestamp, *) by AgentId
    | where LifecycleStatus != "Deleted"
    | where array_length(coalesce(McpServers, dynamic([]))) > 0
    | project AgentId, Timestamp, Name, Platform, CreatedDateTime,
              CurrentOwners = coalesce(Owners, dynamic([])), McpServers);
let CurrentMcp =
    CurrentState
    | mv-expand Mcp = McpServers
    | extend McpName = tostring(Mcp.name)
    | summarize McpServersConfigured = make_set_if(McpName, isnotempty(McpName)) by AgentId;
let BaselineState =
    AgentsInfo
    | where Timestamp between (ago(lookback) .. ago(recent))
    | where LifecycleStatus != "Deleted"
    | summarize arg_max(Timestamp, *) by AgentId
    | project AgentId, PreviousTimestamp = Timestamp,
              PreviousOwners = coalesce(Owners, dynamic([]));
CurrentState
| join kind=inner CurrentMcp on AgentId
| join kind=inner BaselineState on AgentId
| extend AddedOwners = set_difference(CurrentOwners, PreviousOwners)
| where array_length(AddedOwners) > 0
| mv-expand AddedOwnerId = AddedOwners to typeof(string)
| join kind=leftouter IdentityIdtoUPN on $left.AddedOwnerId == $right.AccountObjectId
| extend AddedOwnerUpn = AccountUpn,
         UnresolvedAddedOwnerId = iff(isempty(AccountUpn), AddedOwnerId, "")
| extend AddedOwnerAccountName = tostring(split(AddedOwnerUpn, "@")[0]),
         AddedOwnerAccountUPNSuffix = tostring(split(AddedOwnerUpn, "@")[1])
| project Timestamp, PreviousTimestamp, AgentId, Name, Platform, CreatedDateTime,
         PreviousOwners, CurrentOwners, AddedOwners, AddedOwnerId,
         UnresolvedAddedOwnerId, AddedOwnerUpn, AddedOwnerAccountName,
         AddedOwnerAccountUPNSuffix, McpServersConfigured
| sort by Timestamp desc
Raw source AI Agents - Owner added to MCP-enabled agent · KQL
Esc
Published by Azure/Azure-Sentinel ↗, licensed under MIT ↗. Reproduced here unmodified.
id: c35b5a60-71ee-448c-935d-f60f4a4c7f2b
name: AI Agents - Owner added to MCP-enabled agent
description: |
  Identifies owners newly observed on existing AI agents with MCP servers configured. Review the added owners and audit records to confirm whether privileged access was authorized. Run within 2 days of a change to retain coverage.
requiredDataConnectors: []
tactics:
  - Persistence
  - PrivilegeEscalation
relevantTechniques:
  - T1098
query: |
  let lookback = 14d;
  let recent = 2d;
  let IdentityIdtoUPN = materialize(
      IdentityInfo
      | extend ResolvedAccountUpn = tostring(column_ifexists("AccountUpn", column_ifexists("AccountUPN", ""))),
               IdentityTimestamp = todatetime(column_ifexists("Timestamp", column_ifexists("TimeGenerated", datetime(null))))
      | where IdentityTimestamp >= ago(lookback)
      | where isnotempty(AccountObjectId) and isnotempty(ResolvedAccountUpn)
      | summarize arg_max(IdentityTimestamp, ResolvedAccountUpn) by AccountObjectId
      | project AccountObjectId = tostring(AccountObjectId), AccountUpn = ResolvedAccountUpn);
  let CurrentState = materialize(
      AgentsInfo
      | where Timestamp > ago(recent)
      | summarize arg_max(Timestamp, *) by AgentId
      | where LifecycleStatus != "Deleted"
      | where array_length(coalesce(McpServers, dynamic([]))) > 0
      | project AgentId, Timestamp, Name, Platform, CreatedDateTime,
                CurrentOwners = coalesce(Owners, dynamic([])), McpServers);
  let CurrentMcp =
      CurrentState
      | mv-expand Mcp = McpServers
      | extend McpName = tostring(Mcp.name)
      | summarize McpServersConfigured = make_set_if(McpName, isnotempty(McpName)) by AgentId;
  let BaselineState =
      AgentsInfo
      | where Timestamp between (ago(lookback) .. ago(recent))
      | where LifecycleStatus != "Deleted"
      | summarize arg_max(Timestamp, *) by AgentId
      | project AgentId, PreviousTimestamp = Timestamp,
                PreviousOwners = coalesce(Owners, dynamic([]));
  CurrentState
  | join kind=inner CurrentMcp on AgentId
  | join kind=inner BaselineState on AgentId
  | extend AddedOwners = set_difference(CurrentOwners, PreviousOwners)
  | where array_length(AddedOwners) > 0
  | mv-expand AddedOwnerId = AddedOwners to typeof(string)
  | join kind=leftouter IdentityIdtoUPN on $left.AddedOwnerId == $right.AccountObjectId
  | extend AddedOwnerUpn = AccountUpn,
           UnresolvedAddedOwnerId = iff(isempty(AccountUpn), AddedOwnerId, "")
  | extend AddedOwnerAccountName = tostring(split(AddedOwnerUpn, "@")[0]),
           AddedOwnerAccountUPNSuffix = tostring(split(AddedOwnerUpn, "@")[1])
  | project Timestamp, PreviousTimestamp, AgentId, Name, Platform, CreatedDateTime,
           PreviousOwners, CurrentOwners, AddedOwners, AddedOwnerId,
           UnresolvedAddedOwnerId, AddedOwnerUpn, AddedOwnerAccountName,
           AddedOwnerAccountUPNSuffix, McpServersConfigured
  | sort by Timestamp desc
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: Name
        columnName: AddedOwnerAccountName
      - identifier: UPNSuffix
        columnName: AddedOwnerAccountUPNSuffix
      - identifier: AadUserId
        columnName: AddedOwnerId
version: 1.0.0

Detection rules belong to the projects that publish them and remain under their own licenses. This site indexes and links to them; it claims no rights in them.