Detection rules › Kusto

Azure DevOps Service Connection Addition/Abuse - Historic allow list

Status
available
Severity
medium
Time window
14d
Group by
ActorUPN, DefId, OrganizationName, ProjectId, ProjectName, Type
Source
github.com/Azure/Azure-Sentinel

'This detection builds an allow list of historic service connection use by Builds and Releases and compares to recent history, flagging growth of service connection use which are not manually included in the allow list and not historically included in the allow list Build/Release runs. This is to determine if someone is hijacking a build/release and adding many service connections in order to abuse or dump credentials from service connections.'

MITRE ATT&CK coverage

Rule body kusto

id: 5efb0cfd-063d-417a-803b-562eae5b0301
name: Azure DevOps Service Connection Addition/Abuse - Historic allow list
description: |
  'This detection builds an allow list of historic service connection use by Builds and Releases and compares to recent history, flagging growth of service connection use which are not manually included in the allow list and not historically included in the allow list Build/Release runs.
  This is to determine if someone is hijacking a build/release and adding many service connections in order to abuse or dump credentials from service connections.'
severity: Medium
status: Available
requiredDataConnectors: []
queryFrequency: 6h
queryPeriod: 14d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - Persistence
  - Impact
relevantTechniques:
  - T1098
  - T1496
query: |
  let starttime = 14d;
  let endtime = 6h;
  // Ignore Build/Releases with less/equal this number
  let ServiceConnectionThreshold = 3;
  // New Connections need to exhibit execution of more "new" connections than this number.
  let NewConnectionThreshold = 1;
  // List of Builds/Releases to ignore in your space
  let BypassDefIds = datatable(DefId:string, Type:string, ProjectName:string)
  [
  //"103", "Release", "ProjectA",
  //"42", "Release", "ProjectB",
  //"122", "Build", "ProjectB"
  ];
  let HistoricDefs = ADOAuditLogs
  | where TimeGenerated between (ago(starttime) .. ago(endtime))
  | where OperationName == "Library.ServiceConnectionExecuted"
  | extend DefId = tostring(Data.DefinitionId), Type = tostring(Data.PlanType), ConnectionId = tostring(Data.ConnectionId)
  | summarize HistoricCount = dcount(tostring(ConnectionId)), ConnectionNames = make_set(tostring(Data.ConnectionName))
    by DefId = tostring(DefId), Type = tostring(Type), ProjectId, ProjectName, ActorUPN;
  ADOAuditLogs
  | where TimeGenerated >= ago(endtime)
  | where OperationName == "Library.ServiceConnectionExecuted"
  | extend DefId = tostring(Data.DefinitionId), Type = tostring(Data.PlanType), ConnectionId = tostring(Data.ConnectionId)
  | parse ScopeDisplayName with OrganizationName ' (Organization)'
  | summarize CurrentCount = dcount(tostring(ConnectionId)), ConnectionNames = make_set(tostring(Data.ConnectionName)), StartTime = min(TimeGenerated), EndTime = max(TimeGenerated)
    by OrganizationName, DefId = tostring(DefId), Type = tostring(Type), ProjectId, ProjectName, ActorUPN
  | where CurrentCount > ServiceConnectionThreshold
  | join (HistoricDefs) on ProjectId, DefId, Type, ActorUPN
  | join kind=anti BypassDefIds on $left.DefId==$right.DefId and $left.Type == $right.Type and $left.ProjectName == $right.ProjectName
  | extend link = iff(
  Type == "Build", strcat('https://dev.azure.com/', OrganizationName, '/', ProjectName, '/_build?definitionId=', DefId),
  strcat('https://dev.azure.com/', OrganizationName, '/', ProjectName, '/_release?_a=releases&view=mine&definitionId=', DefId))
  | where CurrentCount >= HistoricCount + NewConnectionThreshold
  | project StartTime, OrganizationName, ProjectName, DefId, link, RecentDistinctServiceConnections = CurrentCount, HistoricDistinctServiceConnections = HistoricCount,
    RecentConnections = ConnectionNames, HistoricConnections = ConnectionNames1, ActorUPN
  | extend timestamp = StartTime
  | 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
version: 1.0.6
kind: Scheduled

Stages and Predicates

Parameters

let starttime = 14d;
let endtime = 6h;
let ServiceConnectionThreshold = 3;
let NewConnectionThreshold = 1;

Let binding: BypassDefIds

let BypassDefIds = datatable(DefId:string, Type:string, ProjectName:string)
[
];

Let binding: HistoricDefs

let HistoricDefs = ADOAuditLogs
| where TimeGenerated between (ago(starttime) .. ago(endtime))
| where OperationName == "Library.ServiceConnectionExecuted"
| extend DefId = tostring(Data.DefinitionId), Type = tostring(Data.PlanType), ConnectionId = tostring(Data.ConnectionId)
| summarize HistoricCount = dcount(tostring(ConnectionId)), ConnectionNames = make_set(tostring(Data.ConnectionName))
  by DefId = tostring(DefId), Type = tostring(Type), ProjectId, ProjectName, ActorUPN;

Derived from starttime, endtime.

Stage 1: source

ADOAuditLogs

Stage 2: where

| where TimeGenerated >= ago(endtime)

Stage 3: where

| where OperationName == "Library.ServiceConnectionExecuted"

Stage 4: extend

| extend DefId = tostring(Data.DefinitionId), Type = tostring(Data.PlanType), ConnectionId = tostring(Data.ConnectionId)

Stage 5: parse

| parse ScopeDisplayName with OrganizationName ' (Organization)'

Stage 6: summarize

| summarize CurrentCount = dcount(tostring(ConnectionId)), ConnectionNames = make_set(tostring(Data.ConnectionName)), StartTime = min(TimeGenerated), EndTime = max(TimeGenerated)
  by OrganizationName, DefId = tostring(DefId), Type = tostring(Type), ProjectId, ProjectName, ActorUPN
Threshold
gt 3

Stage 7: where

| where CurrentCount > ServiceConnectionThreshold

Stage 8: join

| join (HistoricDefs) on ProjectId, DefId, Type, ActorUPN

Stage 9: join (negated)

| join kind=anti BypassDefIds on $left.DefId==$right.DefId and $left.Type == $right.Type and $left.ProjectName == $right.ProjectName

Stage 10: extend

| extend link = iff(
Type == "Build", strcat('https://dev.azure.com/', OrganizationName, '/', ProjectName, '/_build?definitionId=', DefId),
strcat('https://dev.azure.com/', OrganizationName, '/', ProjectName, '/_release?_a=releases&view=mine&definitionId=', DefId))
link =
ifType == "Build"strcat('https://dev.azure.com/', OrganizationName, '/', ProjectName, '/_build?definitionId=', DefId)
elsestrcat('https://dev.azure.com/', OrganizationName, '/', ProjectName, '/_release?_a=releases&view=mine&definitionId=', DefId)

Stage 11: where

| where CurrentCount >= HistoricCount + NewConnectionThreshold

Stage 12: project

| project StartTime, OrganizationName, ProjectName, DefId, link, RecentDistinctServiceConnections = CurrentCount, HistoricDistinctServiceConnections = HistoricCount,
  RecentConnections = ConnectionNames, HistoricConnections = ConnectionNames1, ActorUPN

Stage 13: extend

| extend timestamp = StartTime

Stage 14: 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
CurrentCountgt
  • 3 transforms: cased
OperationNameeq
  • Library.ServiceConnectionExecuted 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
ActorUPNproject
DefIdproject
HistoricConnectionsproject
HistoricDistinctServiceConnectionsproject
OrganizationNameproject
ProjectNameproject
RecentConnectionsproject
RecentDistinctServiceConnectionsproject
StartTimeproject
linkproject
timestampextend
AccountNameextend
AccountUPNSuffixextend