Security

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:

  1. Go to Organization Settings > Pipelines > Settings
  2. Under Required templates, add your security template
  3. 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:

  1. Select the service connection
  2. Go to Approvals and checks
  3. Add Approvals — select the approvers
  4. Add Branch control — restrict to refs/heads/main and refs/heads/release/*
  5. 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:

  1. Go to Pipelines > Environments > [environment] > Approvals and checks
  2. Add Branch control
  3. 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-scripts instead of npm 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.

References

Powered by Contentful