Detection rules › Kusto

Azure DevOps Agent Pool Created Then Deleted

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

'As well as adding build agents to an existing pool to execute malicious activity within a pipeline, an attacker could create a complete new agent pool and use this for execution. Azure DevOps allows for the creation of agent pools with Azure hosted infrastructure or self-hosted infrastructure. Given the additional customizability of self-hosted agents this detection focuses on the creation of new self-hosted pools. To further reduce false positive rates the detection looks for pools created and deleted relatively quickly (within 7 days by default), as an attacker is likely to remove a malicious pool once used in order to reduce/remove evidence of their activity.'

MITRE ATT&CK coverage

Rule body kusto

id: acfdee3f-b794-404a-aeba-ef6a1fa08ad1
name: Azure DevOps Agent Pool Created Then Deleted
description: |
  'As well as adding build agents to an existing pool to execute malicious activity within a pipeline, an attacker could create a complete new agent pool and use this for execution.
  Azure DevOps allows for the creation of agent pools with Azure hosted infrastructure or self-hosted infrastructure. Given the additional customizability of self-hosted agents this   detection focuses on the creation of new self-hosted pools.
  To further reduce false positive rates the detection looks for pools created and deleted relatively quickly (within 7 days by default), as an attacker is likely to remove a malicious pool once used in order to reduce/remove evidence of their activity.'
severity: High
status: Available
requiredDataConnectors: []
queryFrequency: 7d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - DefenseEvasion
relevantTechniques:
  - T1578.002
query: |
  let lookback = 14d;
  let timewindow = 7d;
  ADOAuditLogs
  | where TimeGenerated > ago(lookback)
  | where OperationName =~ "Library.AgentPoolCreated"
  | extend AgentCloudId = tostring(Data.AgentCloudId)
  | extend PoolType = iif(isnotempty(AgentCloudId), "Azure VMs", "Self Hosted")
  // Comment this line out to include cloud pools as well
  | where PoolType == "Self Hosted"
  | extend AgentPoolName = tostring(Data.AgentPoolName)
  | extend AgentPoolId = tostring(Data.AgentPoolId)
  | extend IsHosted = tostring(Data.IsHosted)
  | extend IsLegacy = tostring(Data.IsLegacy)
  | extend timekey = bin(TimeGenerated, timewindow)
  // Join only with pools deleted in the same window
  | join (ADOAuditLogs
  | where TimeGenerated > ago(lookback)
  | where OperationName =~ "Library.AgentPoolDeleted"
  | extend AgentPoolName = tostring(Data.AgentPoolName)
  | extend AgentPoolId = tostring(Data.AgentPoolId)
  | extend timekey = bin(TimeGenerated, timewindow)) on AgentPoolId, timekey
  | project-reorder TimeGenerated, ActorUPN, UserAgent, IpAddress, AuthenticationMechanism, OperationName, AgentPoolName, IsHosted, IsLegacy, 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.5
kind: Scheduled

Stages and Predicates

Parameters

let lookback = 14d;
let timewindow = 7d;

Stage 1: source

ADOAuditLogs

Stage 2: where

| where TimeGenerated > ago(lookback)

Stage 3: where

| where OperationName =~ "Library.AgentPoolCreated"

Stage 4: extend

| extend AgentCloudId = tostring(Data.AgentCloudId)

Stage 5: extend

| extend PoolType = iif(isnotempty(AgentCloudId), "Azure VMs", "Self Hosted")

Stage 6: where

| where PoolType == "Self Hosted"

Stage 7: extend (5 consecutive steps)

| extend AgentPoolName = tostring(Data.AgentPoolName)
| extend AgentPoolId = tostring(Data.AgentPoolId)
| extend IsHosted = tostring(Data.IsHosted)
| extend IsLegacy = tostring(Data.IsLegacy)
| extend timekey = bin(TimeGenerated, timewindow)

Stage 8: join

| join (ADOAuditLogs
| where TimeGenerated > ago(lookback)
| where OperationName =~ "Library.AgentPoolDeleted"
| extend AgentPoolName = tostring(Data.AgentPoolName)
| extend AgentPoolId = tostring(Data.AgentPoolId)
| extend timekey = bin(TimeGenerated, timewindow)) on AgentPoolId, timekey

Stage 9: project-reorder

| project-reorder TimeGenerated, ActorUPN, UserAgent, IpAddress, AuthenticationMechanism, OperationName, AgentPoolName, IsHosted, IsLegacy, Data

Stage 10: extend

| extend timestamp = TimeGenerated

Stage 11: extend

| extend AccountName = tostring(split(ActorUPN, "@")[0]), AccountUPNSuffix = tostring(split(ActorUPN, "@")[1])

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
OperationNameeq
  • Library.AgentPoolCreated
  • Library.AgentPoolDeleted
PoolTypeeq
  • Self Hosted transforms: cased

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
AgentCloudIdextend
PoolTypeextend
AgentPoolNameextend
AgentPoolIdextend
IsHostedextend
IsLegacyextend
timekeyextend
timestampextend
AccountNameextend
AccountUPNSuffixextend