Detection rules › Kusto
External Upstream Source Added to Azure DevOps Feed
'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
| Tactic | Techniques |
|---|---|
| Initial Access | T1199 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.
| Field | Kind | Excluded values |
|---|---|---|
SourceLocation | eq | allowed_locations |
SourceName | eq | allowed_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.
| Field | Kind | Values |
|---|---|---|
Details | match |
|
OperationName | regex_match |
|
UpstreamSourceType | ne |
|
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.
| Field | Source |
|---|---|
FeedName | extend |
FeedId | extend |
UpstreamsAdded | extend |
SourceLocation | extend |
SourceName | extend |
SourceProtocol | extend |
SourceStatus | extend |
timestamp | extend |
AccountName | extend |
AccountUPNSuffix | extend |