Detection rules › Kusto

External Upstream Source Added to Azure DevOps Feed

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

'The detection looks for new external sources added to an Azure DevOps feed. An allow list can be customized to explicitly allow known good sources. An attacker could look to add a malicious feed in order to inject malicious packages into a build pipeline.'

MITRE ATT&CK coverage

TacticTechniques
Initial AccessT1199 Trusted Relationship

Rule body kusto

id: adc32a33-1cd6-46f5-8801-e3ed8337885f
name: External Upstream Source Added to Azure DevOps Feed
description: |
  'The detection looks for new external sources added to an Azure DevOps feed. An allow list can be customized to explicitly allow known good sources. 
  An attacker could look to add a malicious feed in order to inject malicious packages into a build pipeline.'
severity: Medium
status: Available
requiredDataConnectors: []
queryFrequency: 1d
queryPeriod: 1d
triggerOperator: gt
triggerThreshold: 0
tactics:
  - InitialAccess
relevantTechniques:
  - T1199
query: |
  // Add any known allowed sources and source locations to the filter below (the NuGet Gallery has been added here as an example).
  let allowed_sources = dynamic(["NuGet Gallery"]);
  let allowed_locations = dynamic(["https://api.nuget.org/v3/index.json"]);
  ADOAuditLogs
  // Look for feeds created or modified at either the organization or project level
  | where OperationName matches regex "Artifacts.Feed.(Org|Project).Modify"
  | where Details has "UpstreamSources, added"
  | extend FeedName = tostring(Data.FeedName)
  | extend FeedId = tostring(Data.FeedId)
  | extend UpstreamsAdded = Data.UpstreamsAdded
  // As multiple feeds may be added expand these out
  | mv-expand UpstreamsAdded
  // Only focus on external feeds
  | where UpstreamsAdded.UpstreamSourceType !~ "internal"
  | extend SourceLocation = tostring(UpstreamsAdded.Location)
  | extend SourceName = tostring(UpstreamsAdded.Name)
  // Exclude sources and locations in the allow list
  | where SourceLocation !in (allowed_locations) and SourceName !in (allowed_sources)
  | extend SourceProtocol = tostring(UpstreamsAdded.Protocol)
  | extend SourceStatus = tostring(UpstreamsAdded.Status)
  | project-reorder TimeGenerated, OperationName, ScopeDisplayName, ProjectName, FeedName, SourceName, SourceLocation, SourceProtocol, ActorUPN, UserAgent, IpAddress
  | 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.4
kind: Scheduled

Stages and Predicates

Parameters

let allowed_sources = dynamic(["NuGet Gallery"]);
let allowed_locations = dynamic(["https://api.nuget.org/v3/index.json"]);

Stage 1: source

ADOAuditLogs

Stage 2: where

| where OperationName matches regex "Artifacts.Feed.(Org|Project).Modify"

Stage 3: where

| where Details has "UpstreamSources, added"

Stage 4: extend (3 consecutive steps)

| extend FeedName = tostring(Data.FeedName)
| extend FeedId = tostring(Data.FeedId)
| extend UpstreamsAdded = Data.UpstreamsAdded

Stage 5: mv-expand

| mv-expand UpstreamsAdded

Stage 6: where

| where UpstreamsAdded.UpstreamSourceType !~ "internal"

Stage 7: extend

| extend SourceLocation = tostring(UpstreamsAdded.Location)

Stage 8: extend

| extend SourceName = tostring(UpstreamsAdded.Name)

Stage 9: where

| where SourceLocation !in (allowed_locations) and SourceName !in (allowed_sources)

Stage 10: extend

| extend SourceProtocol = tostring(UpstreamsAdded.Protocol)

Stage 11: extend

| extend SourceStatus = tostring(UpstreamsAdded.Status)

Stage 12: project-reorder

| project-reorder TimeGenerated, OperationName, ScopeDisplayName, ProjectName, FeedName, SourceName, SourceLocation, SourceProtocol, ActorUPN, UserAgent, IpAddress

Stage 13: extend

| extend timestamp = TimeGenerated

Stage 14: extend

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

Exclusions

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

FieldKindExcluded values
SourceLocationeqallowed_locations
SourceNameeqallowed_sources

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
Detailsmatch
  • UpstreamSources, added transforms: term
OperationNameregex_match
  • Artifacts.Feed.(Org|Project).Modify
UpstreamSourceTypene
  • internal

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
FeedNameextend
FeedIdextend
UpstreamsAddedextend
SourceLocationextend
SourceNameextend
SourceProtocolextend
SourceStatusextend
timestampextend
AccountNameextend
AccountUPNSuffixextend