Pipeline Security Hardening Checklist
Comprehensive security hardening checklist for Azure DevOps pipelines covering YAML security, service connection protection, secret management, branch controls, environment rules, and audit logging.
Pipeline Security Hardening Checklist
Overview
Your CI/CD pipeline has access to production credentials, deployment keys, and source code. If an attacker compromises a pipeline, they own everything that pipeline can touch. I have seen organizations with airtight application security that leave their pipelines wide open — anyone who can submit a pull request can exfiltrate secrets or inject code into production builds. This article is the checklist I use when hardening Azure DevOps pipelines, covering every control from YAML file security to audit logging.
Prerequisites
- Azure DevOps organization with Project Administrator or Project Collection Administrator permissions
- Existing YAML pipelines to harden (the concepts apply to classic pipelines too, but examples focus on YAML)
- Understanding of Azure DevOps pipeline concepts: stages, jobs, service connections, variable groups, environments
- Access to Organization Settings for organization-level policies
- Node.js 16 or later for the audit automation scripts
1. Securing Pipeline YAML Files
The pipeline YAML file defines what runs. If someone can modify it, they control the build.
Protect the Pipeline File with Branch Policies
# This pipeline file should live in a protected branch
# Set branch policies on the branch that contains your YAML:
# - Require pull request reviews
# - Require build validation
# - Restrict who can push directly
Configure branch policies on the branch containing your pipeline YAML:
# Using Azure CLI
az repos policy approver-count create \
--branch main \
--repository-id "your-repo-id" \
--blocking true \
--enabled true \
--minimum-approver-count 2 \
--creator-vote-counts false \
--allow-downvotes false \
--reset-on-source-push true \
--project "YourProject" \
--org "https://dev.azure.com/your-org"
Disable Pipeline Overrides
Prevent pipeline runs from modifying YAML at runtime:
In Organization Settings > Pipelines > Settings:
- Disable creation of classic build pipelines — force all pipelines to be YAML-based and version-controlled
- Disable creation of classic release pipelines — same reason
- Limit job authorization scope to current project for non-release pipelines — prevents pipelines from accessing repos in other projects
- Limit job authorization scope to current project for release pipelines — same for releases
- Limit variables that can be set at queue time — prevents users from overriding variables when manually running pipelines
Restrict Pipeline Modifications
# Use extends templates to enforce structure
# The template defines security controls that individual pipelines cannot override
# templates/secure-build.yml
parameters:
- name: buildCommand
type: string
- name: testCommand
type: string
steps:
# Security scan runs before any custom code
- script: |
echo "Running mandatory security scan..."
npm audit --production --audit-level=high
displayName: "[SECURITY] Dependency audit"
# Custom build step
- script: ${{ parameters.buildCommand }}
displayName: "Build"
# Custom test step
- script: ${{ parameters.testCommand }}
displayName: "Test"
# Mandatory artifact signing
- script: |
echo "Signing build artifacts..."
sha256sum $(Build.ArtifactStagingDirectory)/* > checksums.sha256
displayName: "[SECURITY] Sign artifacts"
Pipelines extend the template:
# azure-pipelines.yml
extends:
template: templates/secure-build.yml
parameters:
buildCommand: "npm ci && npm run build"
testCommand: "npm test"
Required Templates (Organization Policy)
Enforce that all pipelines must extend an approved template:
- Go to Organization Settings > Pipelines > Settings
- Under Required templates, add your security template
- Any pipeline that does not extend the required template will fail validation
2. Protecting Service Connections
Service connections hold credentials for external services — Azure subscriptions, Docker registries, Kubernetes clusters. They are the most valuable targets in your pipeline configuration.
Restrict Service Connection Access
Project Settings > Service connections > [connection] > Security
Set:
- Pipeline permissions: Only allow specific pipelines (not "Grant access to all pipelines")
- Approvals and checks: Require approval before any pipeline uses this connection
- Branch control: Only allow usage from specific branches
Add Approval Checks
For production service connections, require human approval:
- Select the service connection
- Go to Approvals and checks
- Add Approvals — select the approvers
- Add Branch control — restrict to
refs/heads/mainandrefs/heads/release/* - Add Business hours — prevent production deployments outside business hours
Audit Service Connection Usage
// scripts/audit-service-connections.js
var https = require("https");
var ORG = process.env.AZURE_ORG;
var PROJECT = process.env.AZURE_PROJECT;
var PAT = process.env.AZURE_PAT;
function apiRequest(path, callback) {
var auth = Buffer.from(":" + PAT).toString("base64");
var options = {
hostname: "dev.azure.com",
path: "/" + ORG + "/" + PROJECT + "/_apis" + path,
method: "GET",
headers: {
"Authorization": "Basic " + auth,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 200) {
callback(null, JSON.parse(data));
} else {
callback(new Error("API error: " + res.statusCode + " " + data.substring(0, 200)));
}
});
});
req.on("error", callback);
req.end();
}
apiRequest("/serviceendpoint/endpoints?api-version=7.1", function(err, data) {
if (err) {
console.error("Failed:", err.message);
process.exit(1);
}
var endpoints = data.value || [];
console.log("Service Connections Audit Report");
console.log("================================\n");
console.log("Total connections: " + endpoints.length + "\n");
var issues = [];
endpoints.forEach(function(ep) {
console.log("Name: " + ep.name);
console.log(" Type: " + ep.type);
console.log(" Created by: " + (ep.createdBy ? ep.createdBy.displayName : "unknown"));
console.log(" Is shared: " + ep.isShared);
// Check for overly permissive access
if (ep.data && ep.data.pipelineAccess === "allPipelines") {
issues.push("[HIGH] " + ep.name + " — accessible by ALL pipelines");
console.log(" Access: ALL PIPELINES (INSECURE)");
} else {
console.log(" Access: Restricted to specific pipelines");
}
// Check for missing authorization
if (!ep.authorization || !ep.authorization.scheme) {
issues.push("[MEDIUM] " + ep.name + " — no authorization scheme configured");
}
console.log("");
});
if (issues.length > 0) {
console.log("\n=== SECURITY ISSUES FOUND ===\n");
issues.forEach(function(issue) { console.log(" " + issue); });
console.log("\nRemediation: Restrict service connections to specific pipelines only.");
} else {
console.log("No security issues found.");
}
});
Output:
Service Connections Audit Report
================================
Total connections: 4
Name: Azure-Production
Type: azurerm
Created by: Shane Larson
Is shared: false
Access: Restricted to specific pipelines
Name: Docker-Registry
Type: dockerregistry
Created by: Shane Larson
Is shared: false
Access: ALL PIPELINES (INSECURE)
Name: Kubernetes-Prod
Type: kubernetes
Created by: Admin
Is shared: true
Access: ALL PIPELINES (INSECURE)
Name: NPM-Registry
Type: externalnpmregistry
Created by: Shane Larson
Is shared: false
Access: Restricted to specific pipelines
=== SECURITY ISSUES FOUND ===
[HIGH] Docker-Registry — accessible by ALL pipelines
[HIGH] Kubernetes-Prod — accessible by ALL pipelines
Remediation: Restrict service connections to specific pipelines only.
3. Secret Variable Protection
Use Variable Groups with Key Vault
Never store secrets as plain pipeline variables. Link variable groups to Azure Key Vault:
variables:
- group: Production-Secrets # Linked to Key Vault
steps:
- script: node deploy.js
env:
DB_PASSWORD: $(DatabasePassword) # From Key Vault
API_KEY: $(ApiKey) # From Key Vault
Mark Variables as Secret
If you must use pipeline variables instead of Key Vault, always mark them as secret:
variables:
- name: mySecret
value: $(SecretFromUI) # Set in pipeline UI, marked as secret
Secret variables are:
- Masked in logs (replaced with
***) - Not available in PR builds from forks
- Not decrypted unless explicitly mapped to environment variables
Prevent Secret Exfiltration
A malicious script could base64-encode a secret to bypass log masking:
# Attack: bypass log masking
echo $(secret) | base64 # Outputs encoded secret
Mitigations:
- Restrict who can edit pipeline YAML (branch policies)
- Use extends templates that control which scripts run
- Enable pipeline audit logging to detect unusual patterns
- Use runtime expressions to limit variable access:
steps:
- script: echo "Deploying..."
env:
# Only pass secrets to steps that need them
DB_PASSWORD: $(DatabasePassword)
# condition: only run on main branch
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
4. Branch Control for Pipelines
Restrict Which Branches Can Trigger Production Pipelines
# azure-pipelines.yml
trigger:
branches:
include:
- main
- release/*
exclude:
- feature/*
- experiment/*
pr:
branches:
include:
- main
# Fork builds have restricted access to secrets
Environment Branch Controls
For deployment environments, restrict which branches can deploy:
- Go to Pipelines > Environments > [environment] > Approvals and checks
- Add Branch control
- Set allowed branches:
refs/heads/main, refs/heads/release/*
This prevents feature branches from deploying to staging or production, even if someone modifies the pipeline YAML.
Fork Protection
Pull request builds from forks run with reduced permissions:
- Secret variables are not available
- Service connections are not accessible
- The build runs with read-only access to the repository
Verify these settings are enabled:
Organization Settings > Pipelines > Settings
- Protect access to repositories in YAML pipelines: ON
- Limit job authorization scope to referenced Azure DevOps repositories: ON
5. Agent Pool Security
Restrict Agent Pool Access
Project Settings > Agent pools > [pool] > Security
Set:
- Pipeline permissions: Specific pipelines only
- User permissions: Only administrators can manage
Self-Hosted Agent Hardening
If you run self-hosted agents:
# Run agents as non-root
sudo useradd -m -s /bin/bash azagent
sudo -u azagent ./config.sh
# Limit network access
# Agents should only reach Azure DevOps and deployment targets
iptables -A OUTPUT -d dev.azure.com -j ACCEPT
iptables -A OUTPUT -d vstsagentpackage.azureedge.net -j ACCEPT
iptables -A OUTPUT -d your-deployment-target.com -j ACCEPT
iptables -A OUTPUT -j DROP # Block everything else
# Clean workspace after each build
# In agent capabilities or pipeline:
steps:
- checkout: self
clean: true # Clean workspace before build
# At the end of the pipeline:
- script: |
echo "Cleaning sensitive files..."
rm -rf node_modules/.cache
rm -f .env *.pem *.key
displayName: "Clean workspace"
condition: always()
6. Environment Protection Rules
Environments are the deployment targets. Protect them with gates.
Production Environment Checklist
Pipelines > Environments > production > Approvals and checks:
1. Approvals
- Approvers: [Security team lead, Engineering manager]
- Minimum approvers: 1
- Timeout: 72 hours
- Allow approvers to approve their own runs: No
2. Branch control
- Allowed branches: refs/heads/main, refs/heads/release/*
- Verify branch protection: Yes
3. Business hours
- Timezone: America/Los_Angeles
- Monday-Thursday: 9:00 AM - 4:00 PM
- Friday: 9:00 AM - 1:00 PM
- Weekends: Blocked
4. Exclusive lock
- Only one deployment at a time
- Lock behavior: Sequential
7. Audit Logging
Enable Organization Audit Streaming
Go to Organization Settings > Auditing and enable audit log streaming to:
- Azure Monitor Log Analytics
- Splunk
- Azure Event Grid
Pipeline-Specific Audit Script
// scripts/pipeline-security-audit.js
var https = require("https");
var PAT = process.env.AZURE_PAT;
var ORG = process.env.AZURE_ORG;
function auditRequest(startTime, callback) {
var auth = Buffer.from(":" + PAT).toString("base64");
var options = {
hostname: "auditservice.dev.azure.com",
path: "/" + ORG + "/_apis/audit/auditlog?startTime=" + encodeURIComponent(startTime) + "&api-version=7.1",
method: "GET",
headers: {
"Authorization": "Basic " + auth,
"Accept": "application/json"
}
};
var req = https.request(options, function(res) {
var data = "";
res.on("data", function(chunk) { data += chunk; });
res.on("end", function() {
if (res.statusCode === 200) {
callback(null, JSON.parse(data));
} else {
callback(new Error("Audit API error: " + res.statusCode));
}
});
});
req.on("error", callback);
req.end();
}
var startTime = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
auditRequest(startTime, function(err, data) {
if (err) {
console.error("Audit query failed:", err.message);
process.exit(1);
}
var events = data.decoratedAuditLogEntries || [];
// Filter for security-relevant pipeline events
var securityEvents = events.filter(function(e) {
var securityActions = [
"Pipeline.ModifyPipeline",
"Pipeline.DeletePipeline",
"ServiceEndpoint.Create",
"ServiceEndpoint.Update",
"ServiceEndpoint.Delete",
"Policy.PolicyConfigModified",
"Policy.PolicyConfigRemoved",
"Security.ModifyPermission",
"Library.VariableGroupModified"
];
return securityActions.indexOf(e.actionId) !== -1;
});
console.log("Pipeline Security Audit (Last 7 Days)");
console.log("======================================\n");
console.log("Total audit events: " + events.length);
console.log("Security-relevant events: " + securityEvents.length + "\n");
if (securityEvents.length === 0) {
console.log("No security-relevant changes detected.");
return;
}
securityEvents.forEach(function(e) {
var severity = "INFO";
if (e.actionId.indexOf("Delete") !== -1) { severity = "HIGH"; }
if (e.actionId.indexOf("Security") !== -1) { severity = "HIGH"; }
if (e.actionId.indexOf("ServiceEndpoint") !== -1) { severity = "MEDIUM"; }
console.log("[" + severity + "] " + e.timestamp);
console.log(" Action: " + e.actionId);
console.log(" Actor: " + (e.actorDisplayName || "unknown"));
console.log(" IP: " + (e.ipAddress || "unknown"));
console.log(" Details: " + (e.details || "none"));
console.log("");
});
});
Complete Working Example
A fully hardened pipeline with all security controls applied:
# azure-pipelines-hardened.yml
trigger:
branches:
include:
- main
paths:
exclude:
- "*.md"
- docs/*
pr:
branches:
include:
- main
# Use a restricted agent pool
pool:
name: "Secure-Linux-Pool"
# All secrets from Key Vault, not pipeline variables
variables:
- group: Production-Secrets-KV
- name: nodeVersion
value: "20.x"
# Extend required security template
extends:
template: templates/security-baseline.yml
parameters:
stages:
- stage: Build
displayName: "Build and Security Scan"
jobs:
- job: SecureBuild
steps:
- task: NodeTool@0
inputs:
versionSpec: $(nodeVersion)
- script: npm ci --ignore-scripts
displayName: "Install dependencies (no lifecycle scripts)"
- script: npm audit --production --audit-level=high
displayName: "Dependency vulnerability scan"
- script: npm run lint
displayName: "Static analysis"
- script: npm test -- --coverage
displayName: "Run tests with coverage"
- script: npm run build
displayName: "Build application"
- task: PublishPipelineArtifact@1
inputs:
targetPath: dist
artifact: secure-build
- stage: DeployStaging
displayName: "Deploy to Staging"
dependsOn: Build
jobs:
- deployment: Staging
environment: staging
strategy:
runOnce:
deploy:
steps:
- task: AzureKeyVault@2
inputs:
azureSubscription: "Azure-Staging-Restricted"
KeyVaultName: "kv-staging"
SecretsFilter: "DatabaseUrl,ApiKey"
- task: DownloadPipelineArtifact@2
inputs:
artifact: secure-build
targetPath: $(Pipeline.Workspace)/app
- task: AzureCLI@2
inputs:
azureSubscription: "Azure-Staging-Restricted"
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az webapp deploy \
--name app-staging \
--resource-group rg-staging \
--src-path "$(Pipeline.Workspace)/app" \
--type zip
- stage: DeployProduction
displayName: "Deploy to Production"
dependsOn: DeployStaging
# Production requires manual approval via environment checks
jobs:
- deployment: Production
environment: production # Has approval + branch control + business hours
strategy:
runOnce:
deploy:
steps:
- task: AzureKeyVault@2
inputs:
azureSubscription: "Azure-Production-Restricted"
KeyVaultName: "kv-production"
SecretsFilter: "DatabaseUrl,ApiKey,JwtKey"
- task: DownloadPipelineArtifact@2
inputs:
artifact: secure-build
targetPath: $(Pipeline.Workspace)/app
- task: AzureCLI@2
inputs:
azureSubscription: "Azure-Production-Restricted"
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az webapp deploy \
--name app-production \
--resource-group rg-production \
--src-path "$(Pipeline.Workspace)/app" \
--type zip
- script: |
echo "Production deployment complete"
echo "Build: $(Build.BuildNumber)"
echo "Commit: $(Build.SourceVersion)"
displayName: "Log deployment"
Common Issues and Troubleshooting
"Pipeline does not have permissions to use service connection"
##[error] Pipeline does not have permissions to use the service connection 'Azure-Production-Restricted'. For authorization, please refer to: https://aka.ms/yamlauthz
You restricted the service connection to specific pipelines but did not add this pipeline to the allow list. Go to Project Settings > Service connections > [connection] > Pipeline permissions and add the pipeline explicitly.
"Approval is pending for environment 'production'"
Deployment DeployProduction is waiting on approvals.
Approval is pending for environment 'production'.
This is expected behavior — it means your approval gates are working. The approver needs to approve the deployment in the Azure DevOps portal. Check that the approver has notification preferences set to receive email or Teams alerts for pending approvals.
"Branch 'refs/heads/feature/my-branch' is not allowed to deploy to this environment"
##[error] This pipeline can't deploy to production environment. The check for branch control failed: Branch 'refs/heads/feature/my-branch' is not allowed.
Your environment branch control is blocking deployments from non-allowed branches. This is working as intended. Only branches matching the allowed patterns (usually main and release/*) can deploy. Merge your feature branch to main first.
"Classic pipelines are disabled by organization policy"
Error: Creation of classic build pipelines has been disabled by your organization administrator.
Your admin has enforced YAML-only pipelines. Convert the classic pipeline to YAML. This is a security control — YAML pipelines are version-controlled and reviewable, while classic pipelines are UI-configured and harder to audit.
Best Practices
Treat pipeline YAML like production code. Require pull request reviews for any pipeline changes. An attacker who can modify the YAML can exfiltrate every secret the pipeline has access to.
Never grant service connections to "all pipelines." Every service connection should be restricted to the specific pipelines that need it. The default "allow all" setting is the single most common pipeline security misconfiguration.
Use extends templates to enforce security controls. Individual teams cannot bypass mandatory security scans, artifact signing, or audit logging when they are baked into a required template.
Separate credentials by environment. Staging and production should use different service connections, different Key Vaults, and different service principals. A compromised staging pipeline should not be able to touch production.
Run
npm ci --ignore-scriptsinstead ofnpm install. Post-install scripts in npm packages can execute arbitrary code. By skipping lifecycle scripts, you prevent supply chain attacks through compromised npm packages. Run scripts explicitly only when needed.Review pipeline audit logs weekly. Automate it with the audit script above. Look for: service connection changes, permission modifications, pipeline YAML edits, and variable group updates.
Enable fork protection. Pull request builds from forked repositories should never have access to secrets or service connections. Azure DevOps restricts this by default, but verify the settings are not overridden.