Detection rules › Kusto

Azure DevOps Pipeline modified by a new user

Status
available
Severity
medium
Time window
14d
Group by
AadUserId, ActorUserId
Source
github.com/Azure/Azure-Sentinel

'There are several potential pipeline steps that could be modified by an attacker to inject malicious code into the build cycle. A likely attacker path is the modification to an existing pipeline that they have access to. This detection looks for users modifying a pipeline when they have not previously been observed modifying or creating that pipeline before. This query also joins events with data to Microsoft Entra ID Protection in order to show if the user conducting the action has any associated Microsoft Entra ID Protection alerts. You can also choose to filter this detection to only alert when the user also has Microsoft Entra ID Protection alerts associated with them.'

MITRE ATT&CK coverage

TacticTechniques
Execution
Defense Impairment

Rule body

id: 155e9134-d5ad-4a6f-88f3-99c220040b66
name: Azure DevOps Pipeline modified by a new user
description: |
  'There are several potential pipeline steps that could be modified by an attacker to inject malicious code into the build cycle. A likely attacker path is the modification to an existing pipeline that they have access to. 
  This detection looks for users modifying a pipeline when they have not previously been observed modifying or creating that pipeline before. This query also joins events with data to Microsoft Entra ID Protection in order to show if the user conducting the action has any associated Microsoft Entra ID Protection alerts. You can also choose to filter this detection to only alert when the user also has Microsoft Entra ID Protection alerts associated with them.'
severity: Medium
status: Available
requiredDataConnectors: []
queryFrequency: 1d
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Execution 
  - DefenseEvasion
relevantTechniques:
  - T1578
  - T1569
query: |
  // Set the lookback to determine if user has created pipelines before
  let timeback = 14d;
  // Set the period for detections
  let timeframe = 1d;
  // Get a list of previous Release Pipeline creators to exclude
  let releaseusers = ADOAuditLogs
  | where TimeGenerated > ago(timeback) and TimeGenerated < ago(timeframe)
  | where OperationName in ("Release.ReleasePipelineCreated", "Release.ReleasePipelineModified")
  // We want to look for users performing actions in specific projects so we create this userscope object to match on
  | extend UserScope = strcat(ActorUserId, "-", ProjectName)
  | summarize by UserScope;
  // Get Release Pipeline creations by new users
  ADOAuditLogs
  | where TimeGenerated > ago(timeframe)
  | where OperationName =~ "Release.ReleasePipelineModified"
  | extend UserScope = strcat(ActorUserId, "-", ProjectName)
  | where UserScope !in (releaseusers)
  | extend ActorUPN = tolower(ActorUPN)
  | project-away Id, ActivityId, ActorCUID, ScopeId, ProjectId, TenantId, SourceSystem, UserScope
  // See if any of these users have Azure AD alerts associated with them in the same timeframe
  | join kind = leftouter (
  SecurityAlert
  | where TimeGenerated > ago(timeframe)
  | where ProviderName == "IPC"
  | extend AadUserId = tostring(parse_json(Entities)[0].AadUserId)
  | summarize Alerts=count() by AadUserId) on $left.ActorUserId == $right.AadUserId
  | extend Alerts = iif(isnotempty(Alerts), Alerts, 0)
  // Uncomment the line below to only show results where the user as AADIdP alerts
  //| where Alerts > 0
  | 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.8
kind: Scheduled

Stages and Predicates

Parameters

let timeback = 14d;
let timeframe = 1d;

Let binding: releaseusers used in Stage 5

let releaseusers = ADOAuditLogs
| where TimeGenerated > ago(timeback) and TimeGenerated < ago(timeframe)
| where OperationName in ("Release.ReleasePipelineCreated", "Release.ReleasePipelineModified")
| extend UserScope = strcat(ActorUserId, "-", ProjectName)
| summarize by UserScope;

Stage 1: source

ADOAuditLogs

Stage 2: where

| where TimeGenerated > ago(timeframe)

Stage 3: where

| where OperationName =~ "Release.ReleasePipelineModified"

Stage 4: extend

| extend UserScope = strcat(ActorUserId, "-", ProjectName)

Stage 5: where

| where UserScope !in (releaseusers)

Stage 6: extend

| extend ActorUPN = tolower(ActorUPN)

Stage 7: project-away

| project-away Id, ActivityId, ActorCUID, ScopeId, ProjectId, TenantId, SourceSystem, UserScope

Stage 8: join

| join kind = leftouter (
SecurityAlert
| where TimeGenerated > ago(timeframe)
| where ProviderName == "IPC"
| extend AadUserId = tostring(parse_json(Entities)[0].AadUserId)
| summarize Alerts=count() by AadUserId) on $left.ActorUserId == $right.AadUserId

Stage 9: extend (3 consecutive steps)

| extend Alerts = iif(isnotempty(Alerts), Alerts, 0)
| extend timestamp = TimeGenerated
| extend AccountName = tostring(split(ActorUPN, "@")[0]), AccountUPNSuffix = tostring(split(ActorUPN, "@")[1])

Stage 10: summarize aggregation inside the join branch

summarize by AadUserId

Exclusions

The rule actively suppresses these predicates.

FieldKindExcluded valuesSearch
UserScopeeqreleaseusersexcludes:UserScope field:"UserScope" value:"releaseusers"

Indicators

These rows show field, operator, and value matches.

Output fields

These fields are emitted when the rule matches.

FieldSource
AadUserIdsummarize