Detection rules › Kusto

New Agent Added to Pool by New User or Added to a New OS Type

Status
available
Severity
medium
Time window
14d
Source
github.com/Azure/Azure-Sentinel

'As seen in attacks such as SolarWinds attackers can look to subvert a build process by controlling build servers. Azure DevOps uses agent pools to execute pipeline tasks. An attacker could insert compromised agents that they control into the pools in order to execute malicious code. This query looks for users adding agents to pools they have not added agents to before, or adding agents to a pool of an OS that has not been added to that pool before. This detection has potential for false positives so has a configurable allow list to allow for certain users to be excluded from the logic.'

MITRE ATT&CK coverage

TacticTechniques
ExecutionT1053 Scheduled Task/Job

Rule body kusto

id: 4ce177b3-56b1-4f0e-b83e-27eed4cb0b16
name: New Agent Added to Pool by New User or Added to a New OS Type
description: |
  'As seen in attacks such as SolarWinds attackers can look to subvert a build process by controlling build servers. Azure DevOps uses agent pools to execute pipeline tasks. 
  An attacker could insert compromised agents that they control into the pools in order to execute malicious code. This query looks for users adding agents to pools they have not added agents to before, or adding agents to a pool of an OS that has not been added to that pool before. This detection has potential for false positives so has a configurable allow list to allow for certain users to be excluded from the logic.'
severity: Medium
status: Available
requiredDataConnectors: []
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Execution
relevantTechniques:
  - T1053
query: |
  let lookback = 14d;
  let timeframe = 1d;
  // exclude allowed users from query such as the ADO service
  let allowed_users = dynamic(["Azure DevOps Service"]);
  union
  // Look for agents being added to a pool of a OS type not seen with that pool before
  (ADOAuditLogs
  | where TimeGenerated > ago(lookback) and TimeGenerated < ago(timeframe)
  | where OperationName =~ "Library.AgentAdded"
  | where ActorUPN !in (allowed_users)
  | extend AgentPoolName = tostring(Data.AgentPoolName)
  | extend OsDescription = tostring(Data.OsDescription)
  | where isnotempty(OsDescription)
  | extend OsDescription = tostring(split(OsDescription, "#", 0)[0])
  | project AgentPoolName, OsDescription
  | join kind=rightanti (ADOAuditLogs
  | where TimeGenerated > ago(timeframe)
  | where OperationName == "Library.AgentAdded"
  | extend AgentPoolName = tostring(Data.AgentPoolName)
  | extend OsDescription = tostring(Data.OsDescription)
  | where isnotempty(OsDescription)
  | extend OsDescription = tostring(split(OsDescription, "#", 0)[0])) on AgentPoolName, OsDescription),
  // Look for users addeing agents to a pool that they have not added agents to before.
  (AzureDevOpsAuditing
  | where TimeGenerated > ago(lookback) and TimeGenerated < ago(timeframe)
  | extend AgentPoolName = tostring(Data.AgentPoolName)
  | where ActorUPN !in (allowed_users)
  | project AgentPoolName, ActorUPN
  | join kind=rightanti (AzureDevOpsAuditing
  | where TimeGenerated > ago(timeframe)
  | where OperationName == "Library.AgentAdded"
  | where ActorUPN !in (allowed_users)
  | extend AgentPoolName = tostring(Data.AgentPoolName)
  ) on AgentPoolName, ActorUPN)
  | extend AgentName = tostring(Data.AgentName)
  | extend OsDescription = tostring(Data.OsDescription)
  | extend SystemDetails = Data.SystemCapabilities
  | project-reorder TimeGenerated, OperationName, ScopeDisplayName, AgentPoolName, AgentName, ActorUPN, IpAddress, UserAgent, OsDescription, SystemDetails, Data
  | extend timestamp = TimeGenerated
  | extend AccountName = tostring(split(ActorUPN, "@")[0]), AccountUPNSuffix = tostring(split(ActorUPN, "@")[1])
entityMappings:
  - entityType: Account
    fieldMappings:
      - identifier: FullName
        columnName: ActorUPN
      - identifier: Name
        columnName: AccountName
      - identifier: UPNSuffix
        columnName: AccountUPNSuffix
  - entityType: IP
    fieldMappings:
      - identifier: Address
        columnName: IpAddress
version: 1.0.6
kind: Scheduled

Stages and Predicates

Parameters

let lookback = 14d;
let timeframe = 1d;
let allowed_users = dynamic(["Azure DevOps Service"]);

union (2 sources)

Each leg below queries one source; the rule matches if any leg does. Sources: ADOAuditLogs, AzureDevOpsAuditing

Leg 1: ADOAuditLogs

ADOAuditLogs
| where TimeGenerated > ago(lookback) and TimeGenerated < ago(timeframe)
| where OperationName =~ "Library.AgentAdded"
| where ActorUPN !in (allowed_users)
| extend AgentPoolName = tostring(Data.AgentPoolName)
| extend OsDescription = tostring(Data.OsDescription)
| where isnotempty(OsDescription)
| extend OsDescription = tostring(split(OsDescription, "#", 0)[0])
| project AgentPoolName, OsDescription
| join kind=rightanti (ADOAuditLogs
| where TimeGenerated > ago(timeframe)
| where OperationName == "Library.AgentAdded"
| extend AgentPoolName = tostring(Data.AgentPoolName)
| extend OsDescription = tostring(Data.OsDescription)
| where isnotempty(OsDescription)
| extend OsDescription = tostring(split(OsDescription, "#", 0)[0])) on AgentPoolName, OsDescription

Leg 2: AzureDevOpsAuditing

AzureDevOpsAuditing
| where TimeGenerated > ago(lookback) and TimeGenerated < ago(timeframe)
| extend AgentPoolName = tostring(Data.AgentPoolName)
| where ActorUPN !in (allowed_users)
| project AgentPoolName, ActorUPN
| join kind=rightanti (AzureDevOpsAuditing
| where TimeGenerated > ago(timeframe)
| where OperationName == "Library.AgentAdded"
| where ActorUPN !in (allowed_users)
| extend AgentPoolName = tostring(Data.AgentPoolName)
) on AgentPoolName, ActorUPN

Applied to the combined result

| extend AgentName = tostring(Data.AgentName)
| extend OsDescription = tostring(Data.OsDescription)
| extend SystemDetails = Data.SystemCapabilities
| project-reorder TimeGenerated, OperationName, ScopeDisplayName, AgentPoolName, AgentName, ActorUPN, IpAddress, UserAgent, OsDescription, SystemDetails, Data
| extend timestamp = TimeGenerated
| extend AccountName = tostring(split(ActorUPN, "@")[0]), AccountUPNSuffix = tostring(split(ActorUPN, "@")[1])

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
ActorUPNeqallowed_users
OperationNameeqLibrary.AgentAdded
OsDescriptionis_not_null(no value, null check)
ActorUPNeqallowed_users
OperationNameeqLibrary.AgentAdded

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
ActorUPNin
  • allowed_users transforms: cased
OperationNameeq
  • Library.AgentAdded
OsDescriptionis_not_null
  • (no value, null check)

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
ActorUPNproject
AgentPoolNameproject
AgentNameextend
OsDescriptionextend
SystemDetailsextend
timestampextend
AccountNameextend
AccountUPNSuffixextend