v0.2.0-draft AI Drafted

Azure DevOps Hardening Guide

DevOps Last updated: 2026-08-08

Microsoft DevOps security for pipelines, service connections, and artifact feeds

View:

Overview

Azure DevOps provides deep Microsoft ecosystem integration with enterprise-wide pipeline and repository access. Service connections store long-lived credentials for Azure Resource Manager, AWS, and GCP. OIDC federation (workload identity federation) should replace static secrets, but legacy configurations with stored credentials remain vulnerable to supply chain attacks.

Intended Audience

  • Security engineers hardening DevOps infrastructure
  • Platform engineers managing Azure DevOps
  • GRC professionals assessing CI/CD compliance
  • DevOps teams implementing secure pipelines

How to Use This Guide

  • L1 (Crawl): Essential controls for all organizations
  • L2 (Walk): Enhanced controls for security-sensitive environments
  • L3 (Run): Strictest controls for regulated industries

Scope

This guide covers Azure DevOps security configurations including authentication, service connection hardening, pipeline security, and variable group management.


Table of Contents

  1. Authentication & Access Controls
  2. Service Connection Security
  3. Pipeline Security
  4. Repository Security
  5. Variable & Secret Management
  6. Monitoring & Detection
  7. Compliance Quick Reference

1. Authentication & Access Controls

1.1 Enforce Azure AD Authentication with Conditional Access

Profile Level: L1 (Crawl) CIS Controls: 6.3, 6.5 NIST 800-53: IA-2(1)

Description

Require Azure AD authentication with Conditional Access policies including MFA, device compliance, and location-based restrictions.

Rationale

Why This Matters:

  • Azure DevOps controls code, pipelines, and deployment secrets
  • Service connections store cloud provider credentials
  • Compromised access enables code injection and infrastructure access

Attack Prevented: Credential stuffing and password-only account takeover, MFA bypass through legacy authentication paths, access from unmanaged devices and untrusted networks

ClickOps Implementation

Step 1: Configure Microsoft Entra ID Connection

  1. Navigate to: Organization Settings → Microsoft Entra (formerly Azure Active Directory)
  2. Connect to the Entra ID tenant
  3. Enable: Only allow Microsoft Entra users

Step 2: Create Conditional Access Policy (Entra ID)

  1. Navigate to: Azure Portal → Microsoft Entra ID → Security → Conditional Access
  2. Create policy for Azure DevOps:
    • Users: All users
    • Cloud apps: Azure DevOps
    • Conditions:
      • Sign-in risk: Block high risk
      • Device platforms: Require managed devices (L2)
    • Grant: Require MFA

Step 3: Bind Conditional Access to Non-Interactive Traffic

  1. Navigate to: Organization Settings → Policies
  2. Enable: Enable IP Conditional Access policy validation on non-interactive flows
  3. Without this policy, Entra ID Conditional Access IP restrictions are evaluated on interactive sign-in only. Non-interactive traffic — PATs, SSH, and other token-bearing access — is not bound by the IP conditions, which leaves the most attractive path to an attacker holding a stolen token unrestricted. (Change application connection and security policies)

Step 4: Restrict Legacy and Third-Party Authentication Paths

  1. Navigate to: Organization Settings → Policies
  2. Review the application connection policies:
    • Third-party application access via OAuth: keep disabled. Changed default: Microsoft now ships this policy off by default for new organizations, so the task here is confirming nobody turned it on rather than turning it off. Note the scope — it governs the legacy Azure DevOps OAuth app model only, and does not govern Entra ID OAuth applications, which are controlled in Entra ID. (Change application connection and security policies)
    • SSH authentication: leave enabled only where Git-over-SSH is genuinely needed, and keep Validate SSH key expiration on — it is on by default. With it enabled, expired SSH keys become invalid immediately, and users are notified 7 days before expiry. There is no “restrict to managed keys” setting; key hygiene is enforced through expiration validation, not an allowlist.
    • Alternate credentials: this mechanism no longer exists in Azure DevOps. If your runbook still carries a “disable alternate authentication” step, treat it as historical — verify nothing still authenticates that way, then delete the step rather than hunting for a toggle that was removed.

Step 5: Retire Public Projects

  1. Microsoft has retired public projects: creating new ones is blocked, and existing public projects are scheduled to convert automatically to private in 2027. (Azure DevOps security overview)
  2. Rather than toggling a policy, inventory any public projects that remain and convert them on your own schedule, so the change lands as a planned migration instead of an imposed one.

Compliance Mappings

Framework Control ID Control Description
SOC 2 CC6.1 Logical access controls
NIST 800-53 IA-2(1) MFA for network access

Code Implementation

Code Pack: Terraform
hth-azure-devops-1.1-enforce-azure-ad-authentication.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Disable alternate authentication methods at the project level.
# Azure AD connection and Conditional Access policies are configured in
# the Azure Portal (Entra ID) -- not via the Azure DevOps provider.
# This resource restricts project pipeline settings that weaken auth posture.
# ---------------------------------------------------------------------------

resource "azuredevops_project_features" "auth_hardening" {
  project_id = data.azuredevops_project.target.id

  features = {
    # Disable features that are not required to reduce attack surface
    "artifacts"    = "disabled"
    "testplans"    = "disabled"
    "boards"       = "enabled"
    "repositories" = "enabled"
    "pipelines"    = "enabled"
  }
}

# Look up the target project by name
data "azuredevops_project" "target" {
  name = var.project_name
}

1.2 Implement Project-Level Security Groups

Profile Level: L1 (Crawl) NIST 800-53: AC-3, AC-6

Description

Configure granular project permissions using Azure DevOps security groups.

Rationale

Why This Matters:

  • Default Azure DevOps groups like Project Administrators and Contributors grant broad permissions that violate least privilege
  • Granular security groups separate build, release, and service-connection management so no single role can both write code and push to production
  • Dedicated pipeline service accounts with minimum permissions limit the blast radius if an account or token is compromised
  • Without scoped groups, any Contributor can modify pipelines, service connections, and deployment targets

Attack Prevented: Privilege escalation, lateral movement, unauthorized pipeline modification, insider misuse

ClickOps Implementation

Step 1: Define Security Group Strategy

See the CLI Code Pack below for the recommended security group hierarchy.

Step 2: Configure Project Permissions

  1. Navigate to: Project Settings → Permissions
  2. For each group, configure:
    • Contributors: Cannot manage service connections
    • Build Administrators: Can manage build pipelines only
    • Release Administrators: Can manage release pipelines

Step 3: Restrict Service Account Permissions

  1. Create dedicated service accounts for pipelines
  2. Grant minimum permissions needed
  3. Do not add to Project Administrators

Code Implementation

Code Pack: Terraform
hth-azure-devops-1.2-implement-project-level-security-groups.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Create a dedicated Security Reviewers group for approval gates.
# Built-in groups (Project Administrators, Contributors, Build Administrators)
# are managed via the Azure DevOps UI. This creates the custom group used
# by service connection and environment approval controls.
# ---------------------------------------------------------------------------

resource "azuredevops_group" "security_reviewers" {
  scope        = data.azuredevops_project.target.id
  display_name = var.security_reviewers_group_name
  description  = "Security team members authorized to approve service connection usage and production deployments"
}

# Add members to the security reviewers group
resource "azuredevops_group_membership" "security_reviewers" {
  count = length(var.security_reviewer_members) > 0 ? 1 : 0

  group   = azuredevops_group.security_reviewers.descriptor
  mode    = "add"
  members = [for upn in var.security_reviewer_members : data.azuredevops_users.reviewers[upn].users[*].descriptor[0]]
}

# Look up each reviewer user by principal name
data "azuredevops_users" "reviewers" {
  for_each       = toset(var.security_reviewer_members)
  principal_name = each.value
}

1.3 Configure Personal Access Token Policies

Profile Level: L1 (Crawl) NIST 800-53: IA-5

Description

Restrict PAT creation and enforce expiration policies.

Rationale

Why This Matters:

  • Personal Access Tokens are bearer credentials that bypass interactive MFA and Conditional Access once issued
  • Full-scoped, long-lived PATs leaked in scripts, logs, or repositories grant standing programmatic access to code and pipelines
  • Enforced expiration and scope limits shrink the window an exposed token remains usable
  • Restricting global PATs prevents a single token from reaching every organization the user can access

Attack Prevented: Token theft, credential leakage, MFA bypass, long-lived unauthorized access

ClickOps Implementation

Step 1: Configure the Tenant PAT Policies

The three policies that actually constrain PAT scope and lifetime are tenant policies, not organization policies, and they live in a different place than most guidance suggests. (Manage PATs with policies)

  1. Navigate to: Organization settings → Microsoft Entra
  2. You need the Azure DevOps Administrator role in Entra ID to change these — organization owners without that role cannot set them.
  3. Configure, noting that all three are off by default:
    • Restrict full-scoped personal access token creation — forces every new PAT to carry an explicit scope
    • Maximum personal access token lifespan — set to 90 days or lower
    • Restrict global personal access token creation — stops one token from reaching every organization the user can access
  4. Each policy carries its own Entra allowlist. Use it for the narrow set of identities that genuinely need an exception, and review the allowlists on the same cadence as the tokens themselves — an allowlist entry silently defeats the policy for that identity.

Step 2: Configure the Organization PAT Creation Policy

  1. Navigate to: Organization Settings → Policies
  2. Enable: Restrict personal access token (PAT) creation
  3. This is a separate, organization-scoped control from the tenant policies above. Its subpolicies let you allow PAT creation for packaging scopes only, and maintain an allowlist of users who may still create tokens with any scope.

Step 3: Leave Leaked-Token Auto-Revocation On

Do not disable this. The tenant policy Automatically revoke leaked personal access tokens is on by default, and it revokes PATs that are committed to public GitHub repositories. It is one of the few controls that acts after the mistake has already been made, and turning it off buys nothing.

Step 4: Prefer Entra Tokens Over Service-Account PATs

Long-lived PATs issued to shared service accounts are the hardest PATs to rotate and the ones most likely to end up in a script. Replace them with 1-hour Microsoft Entra tokens, service principals, or managed identities wherever the consuming system supports it. (Azure DevOps security overview)

Step 5: Audit Existing PATs

See the Code Pack below for a PowerShell script that lists all PATs via the Azure DevOps REST API.

Code Implementation

Code Pack: Terraform
hth-azure-devops-1.3-configure-personal-access-token-policies.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# PAT policies are managed at the organization level via the Azure DevOps
# REST API or UI -- not through the Terraform provider. This resource
# configures project pipeline settings to restrict token scope at the
# project level, complementing organization-level PAT restrictions.
# ---------------------------------------------------------------------------

resource "azuredevops_project_pipeline_settings" "pat_restrictions" {
  project_id = data.azuredevops_project.target.id

  # Restrict pipeline job authorization scope to current project
  enforce_job_scope                    = true
  enforce_job_scope_for_release        = true
  enforce_referenced_repo_scoped_token = true

  # Restrict settable variables at queue time (L2+)
  enforce_settable_var = var.profile_level >= 2 ? true : false
}
Code Pack: API Script
hth-azure-devops-1.03-audit-pats.ps1 View source on GitHub ↗
$org = "your-org"
$pat = $env:AZURE_DEVOPS_PAT

$headers = @{
    Authorization = "Basic " + [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes(":$pat"))
}

Invoke-RestMethod -Uri "https://vssps.dev.azure.com/$org/_apis/tokens/pats?api-version=7.1-preview.1" `
    -Headers $headers | ConvertTo-Json

2. Service Connection Security

2.1 Migrate to Workload Identity Federation

Profile Level: L1 (Crawl) - CRITICAL NIST 800-53: IA-5

Description

Replace service connections with stored credentials with workload identity federation (OIDC), eliminating static secrets.

Rationale

Why This Matters:

  • Service connections store long-lived credentials
  • Static credentials don’t expire without rotation
  • OIDC federation provides short-lived, automatically rotated tokens
  • A stored cloud credential in a service connection is a standing path from a compromised pipeline definition straight into the subscription it targets

Attack Prevented: Cloud account takeover through leaked service connection secrets, credential reuse after exposure, long-lived standing access to subscriptions from CI

Deprecation: The Azure DevOps Issuer Is Being Retired

Workload identity federation itself is not going away — the issuer behind it is changing, and existing connections will break if left alone.

  • The Azure DevOps issuer (https://vstoken.dev.azure.com) was deprecated on 2026-07-01 and is scheduled for retirement on 2027-07-01.
  • New service connections are created against the Entra issuer (https://login.microsoftonline.com/) instead.
  • Azure DevOps flags affected connections. Convert one via Project settings → Service connections → (flagged connection) → Update. Where the automatic conversion does not apply, add a federated credential on the app registration manually using the Issuer and Subject values Azure DevOps displays for that connection.
  • Out of scope for the automatic path: multitenant app registrations, and the Azure Government, 21Vianet, and Azure Stack clouds — plan these conversions by hand.

Sources: WIF Azure DevOps issuer retirement · Convert service connections

ClickOps Implementation

Step 1: Create Workload Identity Federation Service Connection

  1. Navigate to: Project Settings → Service connections
  2. Click New service connection → Azure Resource Manager
  3. Select: Workload Identity federation (automatic)
  4. Configure:
    • Subscription: Target subscription
    • Service connection name: Descriptive name
    • Grant access to all pipelines: Disable
  5. Scope the connection to a resource group rather than the whole subscription wherever the pipeline’s work allows it, and avoid classic service connections entirely — they carry broader standing access than the ARM equivalent and are the harder of the two to audit. (Azure DevOps security overview)

Step 2: Migrate Existing Service Connections

  1. Identify connections using stored credentials
  2. Create new OIDC-based connections
  3. Update pipeline references
  4. Delete old credential-based connections
  5. Separately, work the flagged-connection list from the issuer retirement above — a connection can already be OIDC-based and still be pointed at the retiring issuer

Step 3: Restrict Service Connection Access

  1. Navigate to: Service connection → Security
  2. Configure:
    • Pipeline permissions: Specific pipelines only
    • User permissions: Administrators only
    • Allow all pipelines: Disable

Code Implementation (Pipeline)

Code Pack: Terraform
hth-azure-devops-2.1-migrate-to-workload-identity-federation.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Create an Azure Resource Manager service connection using workload
# identity federation (OIDC). This eliminates static secrets by using
# short-lived, automatically rotated tokens. The service connection is
# scoped to specific pipelines -- "grant access to all pipelines" is
# disabled by default.
# ---------------------------------------------------------------------------

resource "azuredevops_serviceendpoint_azurerm" "workload_identity" {
  count = var.azure_subscription_id != "" ? 1 : 0

  project_id            = data.azuredevops_project.target.id
  service_endpoint_name = "${var.project_name}-oidc"
  description           = "Workload Identity Federation - no stored credentials"

  credentials {
    serviceprincipalid = var.service_principal_id
  }

  azurerm_spn_tenantid      = var.azure_tenant_id
  azurerm_subscription_id   = var.azure_subscription_id
  azurerm_subscription_name = var.azure_subscription_name
}

# Restrict service connection access to specific pipelines only
resource "azuredevops_pipeline_authorization" "workload_identity" {
  count = var.azure_subscription_id != "" ? 1 : 0

  project_id  = data.azuredevops_project.target.id
  resource_id = azuredevops_serviceendpoint_azurerm.workload_identity[0].id
  type        = "endpoint"
}
Code Pack: Config
hth-azure-devops-2.01-workload-identity-pipeline.yml View source on GitHub ↗
# azure-pipelines.yml - Using workload identity federation
trigger:
  - main

pool:
  vmImage: 'ubuntu-latest'

stages:
  - stage: Deploy
    jobs:
      - deployment: DeployToAzure
        environment: 'production'
        strategy:
          runOnce:
            deploy:
              steps:
                - task: AzureCLI@2
                  inputs:
                    # Uses workload identity federation - no stored credentials
                    azureSubscription: 'production-oidc-connection'
                    scriptType: 'bash'
                    scriptLocation: 'inlineScript'
                    inlineScript: |
                      az account show
                      az webapp deployment source config-zip \
                        --resource-group myRG \
                        --name myApp \
                        --src $(Pipeline.Workspace)/drop/app.zip

2.2 Audit and Rotate Legacy Service Connections

Profile Level: L1 (Crawl) NIST 800-53: IA-5(1)

Description

Audit service connections with stored credentials and implement rotation schedule.

Rationale

Why This Matters:

  • Service connections holding stored Azure, AWS, or Docker credentials grant direct access to cloud infrastructure
  • Static credentials that are never rotated remain valid indefinitely after exposure or an employee departs
  • A scheduled audit and rotation cadence limits how long a leaked secret stays usable and surfaces forgotten connections
  • Until full OIDC migration is complete, rotation is the primary control reducing exposure of legacy stored secrets

Attack Prevented: Credential reuse, stale secret abuse, cloud account takeover, supply chain compromise

ClickOps Implementation

Step 1: Audit Service Connections

  1. Navigate to: Project Settings → Service connections
  2. Review each connection type:
    • Azure Resource Manager (check for stored creds vs OIDC)
    • AWS (check for access keys)
    • Docker Registry (check for passwords)
    • Generic (check for stored secrets)

Step 2: Document Rotation Schedule

Connection Type Rotation Frequency Last Rotated
Azure (stored creds) 90 days [Date]
AWS Access Keys 90 days [Date]
Docker Registry 90 days [Date]

Step 3: Implement Rotation

See the Code Pack below for a PowerShell script that updates service connection credentials via the Azure DevOps REST API.

Code Implementation

Code Pack: Terraform
hth-azure-devops-2.2-audit-and-rotate-legacy-service-connections.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Legacy service connection auditing and rotation cannot be fully automated
# via Terraform. This data source retrieves all service endpoints in the
# project so they can be reviewed and output for audit purposes.
#
# Rotation workflow:
#   1. Generate new credentials in the target service
#   2. Update the service connection (manual or API)
#   3. Verify pipeline functionality
#   4. Revoke old credentials
# ---------------------------------------------------------------------------

data "azuredevops_serviceendpoint_azurerm" "existing" {
  for_each = toset(var.legacy_service_connection_ids)

  project_id            = data.azuredevops_project.target.id
  service_endpoint_id   = each.value
}
Code Pack: API Script
hth-azure-devops-2.02-rotate-service-connection.ps1 View source on GitHub ↗
# Rotate service connection credentials
# 1. Generate new credentials in target service
# 2. Update service connection
# 3. Verify pipeline functionality
# 4. Revoke old credentials

$connectionId = "connection-guid"
$projectId = "project-guid"

$body = @{
    name = "Updated Connection"
    authorization = @{
        parameters = @{
            serviceprincipalkey = "new-secret-value"
        }
    }
} | ConvertTo-Json

Invoke-RestMethod -Method Put `
    -Uri "https://dev.azure.com/$org/$projectId/_apis/serviceendpoint/endpoints/$connectionId?api-version=7.1" `
    -Headers $headers -Body $body -ContentType "application/json"

2.3 Implement Service Connection Approval Gates

Profile Level: L2 (Walk) NIST 800-53: CM-3

Description

Require approval for pipeline use of sensitive service connections.

Rationale

Why This Matters:

  • Approval checks insert human review before a pipeline can use a connection that deploys to production or touches sensitive cloud resources
  • Branch control ensures only protected branches can invoke privileged connections, blocking exploits from feature branches or forks
  • Without gates, a malicious or compromised pipeline definition can silently use a powerful connection to reach production
  • Approvals create an auditable record of who authorized each sensitive deployment

Attack Prevented: Poisoned pipeline execution, unauthorized production deployment, malicious YAML changes

ClickOps Implementation

Step 1: Configure Approvals and Checks

  1. Navigate to: Service connection → Approvals and checks
  2. Add checks:
    • Required approvers: Security team member
    • Business hours: Production deployments only during business hours
    • Branch control: Only from protected branches

Code Implementation

Code Pack: Terraform
hth-azure-devops-2.3-implement-service-connection-approval-gates.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Require approval before pipelines can use sensitive service connections.
# This applies check_approval and check_branch_control to the workload
# identity service connection. Only created at L2+ profile levels.
# ---------------------------------------------------------------------------

# Approval gate: require security team sign-off before service connection use
resource "azuredevops_check_approval" "service_connection_approval" {
  count = var.profile_level >= 2 && var.azure_subscription_id != "" ? 1 : 0

  project_id           = data.azuredevops_project.target.id
  target_resource_id   = azuredevops_serviceendpoint_azurerm.workload_identity[0].id
  target_resource_type = "endpoint"

  requester_can_approve = false
  approvers             = var.service_connection_approvers
}

# Branch control: only allow service connection use from protected branches
resource "azuredevops_check_branch_control" "service_connection_branch" {
  count = var.profile_level >= 2 && var.azure_subscription_id != "" ? 1 : 0

  project_id           = data.azuredevops_project.target.id
  target_resource_id   = azuredevops_serviceendpoint_azurerm.workload_identity[0].id
  target_resource_type = "endpoint"

  display_name                = "Branch Control - Protected Branches Only"
  allowed_branches            = "refs/heads/main,refs/heads/release/*"
  verify_branch_protection    = true
  ignore_unknown_protection_status = false
}

# Business hours check: restrict production deployments to business hours (L3)
resource "azuredevops_check_business_hours" "service_connection_hours" {
  count = var.profile_level >= 3 && var.azure_subscription_id != "" ? 1 : 0

  project_id           = data.azuredevops_project.target.id
  target_resource_id   = azuredevops_serviceendpoint_azurerm.workload_identity[0].id
  target_resource_type = "endpoint"

  display_name = "Business Hours - Weekdays 09:00-17:00 UTC"
  time_zone    = "UTC"

  monday {
    start_time = "09:00"
    end_time   = "17:00"
  }
  tuesday {
    start_time = "09:00"
    end_time   = "17:00"
  }
  wednesday {
    start_time = "09:00"
    end_time   = "17:00"
  }
  thursday {
    start_time = "09:00"
    end_time   = "17:00"
  }
  friday {
    start_time = "09:00"
    end_time   = "17:00"
  }
}

3. Pipeline Security

3.1 Implement YAML Pipeline Security

Profile Level: L1 (Crawl) NIST 800-53: CM-7

Description

Configure secure YAML pipeline practices and restrict classic pipelines.

Rationale

Why This Matters:

  • YAML pipelines live in source control, so every change is reviewable, versioned, and subject to branch policies — unlike classic UI pipelines
  • Requiring reviews on azure-pipelines.yml prevents an attacker from injecting build steps that exfiltrate secrets or tamper with artifacts
  • Disabling classic pipelines removes a parallel, harder-to-audit path that bypasses code review
  • Pipelines run with access to secrets and deployment credentials, making the definition itself a critical security boundary

Attack Prevented: Pipeline injection, secret exfiltration, build tampering, supply chain attacks

ClickOps Implementation

Step 1: Disable Classic Pipelines (L2)

  1. Navigate to: Organization Settings → Pipelines → Settings
  2. Disable:
    • Disable creation of classic build pipelines: Enable
    • Disable creation of classic release pipelines: Enable

Step 2: Require YAML Pipeline Reviews

  1. Navigate to: Project Settings → Repositories → Policies
  2. Configure branch policies for azure-pipelines.yml:
    • Require approval: Enable
    • Minimum reviewers: 2

Step 3: Implement Secure Pipeline Template

See the CLI Code Pack below for a secure pipeline template with build, security scan, and deploy stages.

Code Implementation

Code Pack: Terraform
hth-azure-devops-3.1-implement-yaml-pipeline-security.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Pipeline settings for this control are consolidated in the
# azuredevops_project_pipeline_settings resource defined in Control 1.3
# (hth-azure-devops-1.3-configure-personal-access-token-policies.tf).
#
# The Terraform provider allows only one pipeline_settings resource per
# project. The following settings from Control 1.3 implement this control:
#
#   enforce_job_scope                    = true   (L1)
#   enforce_job_scope_for_release        = true   (L1)
#   enforce_referenced_repo_scoped_token = true   (L1)
#   enforce_settable_var                 = true   (L2+)
#
# Classic pipeline creation is disabled at the organization level via
# the Azure DevOps UI:
#   Organization Settings > Pipelines > Settings
#     - Disable creation of classic build pipelines: Enable
#     - Disable creation of classic release pipelines: Enable
# ---------------------------------------------------------------------------

# No additional resources -- see Control 1.3 for pipeline settings.

3.2 Configure Pipeline Permissions and Approvals

Profile Level: L1 (Crawl) NIST 800-53: AC-3

Description

Restrict pipeline access to resources and require approvals for production.

Rationale

Why This Matters:

  • Environment approvals require a human to authorize production deployments, stopping unreviewed or fully automated pushes to live systems
  • Branch control limits deployments to the main branch, preventing release of unvetted code from other branches
  • Scoping pipeline permissions to specific users and groups prevents arbitrary pipelines from queuing builds or consuming protected resources
  • Production deployment paths carry the highest blast radius and need the strongest authorization controls

Attack Prevented: Unauthorized deployment, unreviewed code release, resource abuse, privilege escalation

ClickOps Implementation

Step 1: Configure Environment Approvals

  1. Navigate to: Pipelines → Environments → production
  2. Add approvals and checks:
    • Approvers: Required for deployment
    • Branch control: Only main branch
    • Business hours: Optional restriction

Step 2: Configure Pipeline Permissions

  1. Navigate to: Pipeline → Security
  2. Configure:
    • Pipeline permissions: Specific users/groups
    • Queue builds: Restricted to authorized users

Code Implementation

Code Pack: Terraform
hth-azure-devops-3.2-configure-pipeline-permissions-and-approvals.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Create deployment environments with approval gates. Production
# environments require approval before pipeline deployment jobs can
# proceed. Staging environments are created without approval for L1,
# with approval added at L2+.
# ---------------------------------------------------------------------------

# Production environment with mandatory approval
resource "azuredevops_environment" "production" {
  project_id  = data.azuredevops_project.target.id
  name        = var.production_environment_name
  description = "Production deployment environment - requires approval"
}

# Staging environment
resource "azuredevops_environment" "staging" {
  project_id  = data.azuredevops_project.target.id
  name        = var.staging_environment_name
  description = "Staging deployment environment"
}

# Approval gate for production environment
resource "azuredevops_check_approval" "production_approval" {
  count = length(var.deployment_approvers) > 0 ? 1 : 0

  project_id           = data.azuredevops_project.target.id
  target_resource_id   = azuredevops_environment.production.id
  target_resource_type = "environment"

  requester_can_approve = false
  approvers             = var.deployment_approvers
}

# Branch control for production: only deploy from main branch
resource "azuredevops_check_branch_control" "production_branch" {
  project_id           = data.azuredevops_project.target.id
  target_resource_id   = azuredevops_environment.production.id
  target_resource_type = "environment"

  display_name                = "Branch Control - Main Branch Only"
  allowed_branches            = "refs/heads/main"
  verify_branch_protection    = true
  ignore_unknown_protection_status = false
}

# Business hours restriction for production (L3)
resource "azuredevops_check_business_hours" "production_hours" {
  count = var.profile_level >= 3 ? 1 : 0

  project_id           = data.azuredevops_project.target.id
  target_resource_id   = azuredevops_environment.production.id
  target_resource_type = "environment"

  display_name = "Business Hours - Production Deployments Only"
  time_zone    = "UTC"

  monday {
    start_time = "09:00"
    end_time   = "17:00"
  }
  tuesday {
    start_time = "09:00"
    end_time   = "17:00"
  }
  wednesday {
    start_time = "09:00"
    end_time   = "17:00"
  }
  thursday {
    start_time = "09:00"
    end_time   = "17:00"
  }
  friday {
    start_time = "09:00"
    end_time   = "17:00"
  }
}

# Exclusive lock for production deployments (L2+) -- prevent concurrent deploys
resource "azuredevops_check_exclusive_lock" "production_lock" {
  count = var.profile_level >= 2 ? 1 : 0

  project_id           = data.azuredevops_project.target.id
  target_resource_id   = azuredevops_environment.production.id
  target_resource_type = "environment"
}

3.3 Secure Agent Pool Configuration

Profile Level: L1 (Crawl) NIST 800-53: SC-7

Description

Configure agent pools with appropriate security controls.

Rationale

Why This Matters:

  • Self-hosted agents execute pipeline code and hold cached credentials, so a shared or persistent agent can leak secrets between jobs
  • Tiered pools isolate production and security workloads from lower-trust development builds, containing a compromised agent
  • Restricting pool permissions to specific pipelines prevents an untrusted pipeline from running on a privileged production agent
  • Running agents under dedicated least-privilege service accounts limits what an exploited build can do to the host

Attack Prevented: Cross-job credential theft, agent poisoning, lateral movement, privilege escalation

ClickOps Implementation

Step 1: Create Tiered Agent Pools

  1. Azure Pipelines – Microsoft-hosted, ephemeral (built-in)
  2. Development-Agents – self-hosted, lower trust
  3. Production-Agents – self-hosted, restricted access
  4. Security-Agents – isolated, scanning tools only

Step 2: Configure Pool Permissions

  1. Navigate to: Organization Settings → Agent pools
  2. For production pool:
    • Pipeline permissions: Production pipelines only
    • User permissions: Administrators only

Step 3: Self-Hosted Agent Security

See the Code Pack below for a PowerShell script that installs a self-hosted agent with security best practices (service account, unattended configuration).

Code Implementation

Code Pack: Terraform
hth-azure-devops-3.3-secure-agent-pool-configuration.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Create tiered agent pools with appropriate access restrictions.
# Production and security agent pools are isolated from development
# workloads. Pipeline authorization restricts which pipelines can use
# each pool.
#
# Pool hierarchy:
#   Azure Pipelines (Microsoft-hosted, ephemeral) -- built-in, not managed
#   Production-Agents (self-hosted, restricted access)
#   Security-Agents (isolated, scanning tools only) -- L2+
# ---------------------------------------------------------------------------

# Production agent pool -- restricted to production pipelines
resource "azuredevops_agent_pool" "production" {
  name           = var.agent_pool_name
  auto_provision = false
  auto_update    = true
}

# Queue the production pool into the project
resource "azuredevops_agent_queue" "production" {
  project_id    = data.azuredevops_project.target.id
  agent_pool_id = azuredevops_agent_pool.production.id
}

# Restrict production pool access -- do not auto-authorize all pipelines
resource "azuredevops_pipeline_authorization" "production_pool" {
  project_id  = data.azuredevops_project.target.id
  resource_id = azuredevops_agent_queue.production.id
  type        = "queue"
}

# Security-isolated agent pool for scanning tools (L2+)
resource "azuredevops_agent_pool" "security" {
  count = var.profile_level >= 2 ? 1 : 0

  name           = var.security_agent_pool_name
  auto_provision = false
  auto_update    = true
}

resource "azuredevops_agent_queue" "security" {
  count = var.profile_level >= 2 ? 1 : 0

  project_id    = data.azuredevops_project.target.id
  agent_pool_id = azuredevops_agent_pool.security[0].id
}

resource "azuredevops_pipeline_authorization" "security_pool" {
  count = var.profile_level >= 2 ? 1 : 0

  project_id  = data.azuredevops_project.target.id
  resource_id = azuredevops_agent_queue.security[0].id
  type        = "queue"
}
Code Pack: CLI Script
hth-azure-devops-3.03-install-agent.ps1 View source on GitHub ↗
# Agent installation with security
# Run as service account (not admin)
# Limit network access
# Enable audit logging

.\config.cmd --unattended `
    --url https://dev.azure.com/your-org `
    --auth PAT `
    --token $env:AGENT_PAT `
    --pool "Production-Agents" `
    --agent $env:COMPUTERNAME `
    --runAsService `
    --windowsLogonAccount "DOMAIN\svc-agent"

4. Repository Security

4.1 Configure Branch Policies

Profile Level: L1 (Crawl) NIST 800-53: CM-3

Description

Implement branch policies to enforce code review and prevent direct pushes.

Rationale

Why This Matters:

  • Required reviewers ensure no single developer can merge code unilaterally, catching malicious or accidental changes before they reach main
  • Build validation blocks merges that fail security scans or tests, keeping vulnerable code out of protected branches
  • Path-based policies route changes to pipeline and infrastructure files to the right security and platform reviewers
  • Without branch protection, a compromised account can push directly to main and trigger deployments without oversight

Attack Prevented: Unauthorized code changes, malicious commits, review bypass, insider tampering

ClickOps Implementation

Step 1: Configure Protected Branches

  1. Navigate to: Repos → Branches → main → Branch policies
  2. Enable:
    • Require a minimum number of reviewers: 2
    • Check for linked work items: Required
    • Check for comment resolution: Required
    • Build validation: Required pipeline must pass
    • Automatically include reviewers: Code owners

Step 2: Configure Path-Based Policies

  1. Add path filters for sensitive directories:
    • azure-pipelines.yml: Require security team review
    • terraform/: Require platform team review

Step 3: Require Advanced Security Status Checks on Pull Requests

If you have GitHub Advanced Security for Azure DevOps enabled (see 4.2), its findings can gate merges rather than merely appearing in a tab.

  1. Navigate to: Project settings → Repos → Policies → Status checks
  2. Add a status check with Genre AdvancedSecurity and one of:
    • AdvancedSecurity/AllHighAndCritical — blocks the merge while any high or critical alert exists on the branch
    • AdvancedSecurity/NewHighAndCritical — blocks only on alerts the pull request introduces, which is the practical starting point for a repository with existing findings
  3. Start with NewHighAndCritical to stop the bleeding, then move to AllHighAndCritical once the backlog is worked down. (Configure GitHub Advanced Security features)

Code Implementation

Code Pack: Terraform
hth-azure-devops-4.1-configure-branch-policies.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Implement branch policies on the default branch to enforce code review,
# prevent direct pushes, and require build validation. Minimum reviewer
# count scales with profile level (L1: 1, L2: 2, L3: 3).
# ---------------------------------------------------------------------------

# Require minimum number of reviewers for pull requests
resource "azuredevops_branch_policy_min_reviewers" "main" {
  count = var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    reviewer_count                         = var.min_reviewer_count
    submitter_can_vote                     = false
    last_pusher_cannot_approve             = true
    allow_completion_with_rejects_or_waits = false
    on_push_reset_approved_votes           = true

    scope {
      repository_id  = var.repository_id
      repository_ref = var.default_branch
      match_type     = "Exact"
    }
  }
}

# Require comment resolution before merge
resource "azuredevops_branch_policy_comment_resolution" "main" {
  count = var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    scope {
      repository_id  = var.repository_id
      repository_ref = var.default_branch
      match_type     = "Exact"
    }
  }
}

# Require linked work items
resource "azuredevops_branch_policy_work_item_linking" "main" {
  count = var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    scope {
      repository_id  = var.repository_id
      repository_ref = var.default_branch
      match_type     = "Exact"
    }
  }
}

# Restrict merge types -- allow squash and rebase only (L2+)
resource "azuredevops_branch_policy_merge_types" "main" {
  count = var.profile_level >= 2 && var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    allow_squash                  = true
    allow_rebase_and_fast_forward = true
    allow_basic_no_fast_forward   = false
    allow_rebase_with_merge       = false

    scope {
      repository_id  = var.repository_id
      repository_ref = var.default_branch
      match_type     = "Exact"
    }
  }
}

# Build validation -- require CI pipeline to pass before merge
resource "azuredevops_branch_policy_build_validation" "main" {
  count = var.build_validation_definition_id > 0 && var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    display_name        = "CI Build Validation"
    build_definition_id = var.build_validation_definition_id
    valid_duration      = 720
    filename_patterns   = []

    scope {
      repository_id  = var.repository_id
      repository_ref = var.default_branch
      match_type     = "Exact"
    }
  }
}

# Auto-reviewers for pipeline YAML changes (L2+)
resource "azuredevops_branch_policy_auto_reviewers" "pipeline_yaml" {
  count = var.profile_level >= 2 && var.repository_id != "" && length(var.pipeline_yaml_reviewers) > 0 ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    auto_reviewer_ids  = var.pipeline_yaml_reviewers
    submitter_can_vote = false
    message            = "Pipeline YAML changes require security team review"
    path_filters       = ["azure-pipelines.yml", "azure-pipelines/*.yml", ".azuredevops/*.yml"]

    scope {
      repository_id  = var.repository_id
      repository_ref = var.default_branch
      match_type     = "Exact"
    }
  }
}

# Auto-reviewers for Terraform changes (L2+)
resource "azuredevops_branch_policy_auto_reviewers" "terraform" {
  count = var.profile_level >= 2 && var.repository_id != "" && length(var.pipeline_yaml_reviewers) > 0 ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    auto_reviewer_ids  = var.pipeline_yaml_reviewers
    submitter_can_vote = false
    message            = "Terraform changes require platform team review"
    path_filters       = ["terraform/*", "*.tf"]

    scope {
      repository_id  = var.repository_id
      repository_ref = var.default_branch
      match_type     = "Exact"
    }
  }
}

4.2 Enable Secret and Code Scanning with GitHub Advanced Security

Profile Level: L1 (Crawl) NIST 800-53: RA-5

Description

Enable GitHub Advanced Security for Azure DevOps (GHAzDO) to detect secrets committed to repositories and block new ones at push time.

Rationale

Why This Matters:

  • Secrets committed to repositories are exposed to everyone with read access and persist in git history even after deletion
  • Automated credential scanning catches keys, tokens, and passwords before they merge, enabling immediate revocation
  • Hardcoded secrets in code are a leading cause of cloud breaches and supply chain compromise
  • Continuous scanning in CI provides defense in depth alongside developer discipline and pre-commit hooks

Attack Prevented: Secret leakage, hardcoded credential exposure, repository data theft, cloud account takeover

Prerequisites

  • The scanning capability is licensed separately as GitHub Advanced Security for Azure DevOps. It is also sold unbundled as GitHub Secret Protection for Azure DevOps (secret scanning and push protection) and GitHub Code Security for Azure DevOps (code scanning and dependency scanning), so you can buy the secret-scanning half alone.
  • Project Administrator or organization-level permissions, depending on the scope you enable at.

ClickOps Implementation

Step 1: Enable at the Right Scope

  1. Navigate to: Organization settings → Repositories
  2. Use Enable all to turn Advanced Security on across the organization, or enable it per project or per repository if you are rolling out incrementally.
  3. Enabling Secret Protection automatically turns on both push protection and repository secret scanning for the scope you enabled — you do not configure those separately.

Step 2: Work the Alerts

  1. Navigate to the repository’s Advanced Security tab
  2. Triage secret alerts by revoking the exposed credential first and removing it from the code second — rotation is the control, deletion from the branch is cleanup.
  3. Gate merges on findings using the Advanced Security status checks described in 4.1.

Source: Configure GitHub Advanced Security features

Code Implementation

Code Pack: Terraform
hth-azure-devops-4.2-enable-credential-scanning.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Enable repository-level policies that block pushes containing secrets
# or credentials. The azuredevops_repository_policy_check_credentials
# resource prevents accidental secret commits at the Git layer.
#
# GitHub Advanced Security for Azure DevOps (GHAzDO) secret/code scanning
# is enabled per-org/project/repo in the console (see guide 4.2); its
# enablement has no Terraform resource in the azuredevops provider.
# ---------------------------------------------------------------------------

# Block pushes that contain credentials or secrets
resource "azuredevops_repository_policy_check_credentials" "block_secrets" {
  count = var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    scope {
      repository_id = var.repository_id
    }
  }
}

# Enforce path length limits to prevent path traversal abuse (L2+)
resource "azuredevops_repository_policy_max_path_length" "path_length" {
  count = var.profile_level >= 2 && var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    max_path_length = 260

    scope {
      repository_id = var.repository_id
    }
  }
}

# Enforce maximum file size to prevent binary blob abuse (L2+)
resource "azuredevops_repository_policy_max_file_size" "file_size" {
  count = var.profile_level >= 2 && var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    max_file_size = 50

    scope {
      repository_id = var.repository_id
    }
  }
}

# Block reserved names in repository paths (L2+)
resource "azuredevops_repository_policy_reserved_names" "reserved_names" {
  count = var.profile_level >= 2 && var.repository_id != "" ? 1 : 0

  project_id = data.azuredevops_project.target.id
  enabled    = true
  blocking   = true

  settings {
    scope {
      repository_id = var.repository_id
    }
  }
}
Code Pack: Config
hth-azure-devops-4.02-credential-scanning-pipeline.yml View source on GitHub ↗
# azure-pipelines.yml - Credential scanning
trigger:
  - main
  - feature/*

pool:
  vmImage: 'ubuntu-latest'

steps:
  - task: MicrosoftSecurityDevOps@1
    displayName: 'Microsoft Security DevOps'
    inputs:
      categories: 'secrets,code'

  - task: PublishSecurityAnalysisLogs@3
    condition: always()

5. Variable & Secret Management

5.1 Secure Variable Groups

Profile Level: L1 (Crawl) NIST 800-53: SC-28

Description

Configure variable groups with appropriate security controls.

Rationale

Why This Matters:

  • Variable groups distribute secrets to pipelines, so unrestricted access lets any pipeline read production credentials
  • Linking groups to Azure Key Vault centralizes secret storage, rotation, and access auditing instead of holding plaintext in Azure DevOps
  • Scoping pipeline and user permissions ensures only authorized pipelines and administrators can consume sensitive variable groups
  • Separating production, staging, and shared configuration prevents lower environments from leaking production secrets

Attack Prevented: Secret exposure, unauthorized credential access, environment crossover, privilege escalation

ClickOps Implementation

Step 1: Create Environment-Specific Variable Groups

  1. Navigate to: Pipelines → Library → Variable groups
  2. Create groups:
    • production-secrets (linked to Key Vault)
    • staging-secrets
    • shared-config

Step 2: Link to Azure Key Vault

  1. Create variable group linked to Key Vault
  2. Configure:
    • Azure subscription: Service connection
    • Key vault name: Production vault
    • Secrets: Select required secrets

Step 3: Configure Variable Group Permissions

  1. Navigate to: Variable group → Security
  2. Configure:
    • Pipeline permissions: Specific pipelines only
    • User permissions: Administrators only

Code Implementation

Code Pack: Terraform
hth-azure-devops-5.1-secure-variable-groups.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Create environment-specific variable groups with appropriate access
# controls. Production secrets are linked to Azure Key Vault so that
# secret values are never stored in Azure DevOps. Non-secret
# configuration uses standard variable groups.
#
# Variable group hierarchy:
#   production-secrets   (Key Vault linked)
#   staging-secrets      (Key Vault linked or standard)
#   shared-config        (non-secret configuration)
# ---------------------------------------------------------------------------

# Production secrets variable group linked to Azure Key Vault
resource "azuredevops_variable_group" "production_secrets" {
  count = var.key_vault_name != "" ? 1 : 0

  project_id   = data.azuredevops_project.target.id
  name         = "production-secrets"
  description  = "Production secrets linked to Azure Key Vault - managed by HTH"
  allow_access = false

  key_vault {
    name                = var.key_vault_name
    service_endpoint_id = var.key_vault_service_connection_id
  }

  dynamic "variable" {
    for_each = var.key_vault_secrets
    content {
      name = variable.key
    }
  }
}

# Shared non-secret configuration variable group
resource "azuredevops_variable_group" "shared_config" {
  project_id   = data.azuredevops_project.target.id
  name         = "shared-config"
  description  = "Shared non-secret configuration - managed by HTH"
  allow_access = false

  variable {
    name  = "ENVIRONMENT"
    value = "production"
  }

  variable {
    name  = "HTH_MANAGED"
    value = "true"
  }
}

# Restrict production secrets variable group to specific pipelines only
resource "azuredevops_pipeline_authorization" "production_secrets" {
  count = var.key_vault_name != "" ? 1 : 0

  project_id  = data.azuredevops_project.target.id
  resource_id = azuredevops_variable_group.production_secrets[0].id
  type        = "variablegroup"
}

5.2 Use Runtime Parameters for Secrets

Profile Level: L2 (Walk) NIST 800-53: SC-28

Description

Pass secrets at runtime rather than storing in pipelines. See the CLI Code Pack below for a pipeline YAML example using runtime parameters.

Rationale

Why This Matters:

  • Passing secrets at runtime keeps them out of pipeline definitions stored in source control and out of git history
  • Runtime parameters reduce the chance of secrets being logged or echoed during build steps
  • Secrets embedded in pipeline YAML are visible to anyone with repository read access and survive in version history
  • Decoupling secret values from pipeline code limits exposure when a definition is forked, cloned, or shared

Attack Prevented: Secret leakage in source control, credential exposure in logs, history mining

Code Implementation

Code Pack: Terraform
hth-azure-devops-5.2-use-runtime-parameters-for-secrets.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Runtime parameters are a YAML pipeline feature -- they cannot be
# configured via Terraform. This control enforces the project-level
# pipeline setting that restricts settable variables at queue time,
# ensuring that only explicitly declared parameters can be set.
#
# The enforce_settable_var setting prevents unauthorized variable
# injection during pipeline execution.
#
# NOTE: The actual runtime parameter YAML pattern is documented in the
# guide and implemented in pipeline definitions, not infrastructure code.
# ---------------------------------------------------------------------------

# Approval gate for variable group access (L2+)
resource "azuredevops_check_approval" "variable_group_approval" {
  count = var.profile_level >= 2 && var.key_vault_name != "" && length(var.deployment_approvers) > 0 ? 1 : 0

  project_id           = data.azuredevops_project.target.id
  target_resource_id   = azuredevops_variable_group.production_secrets[0].id
  target_resource_type = "variablegroup"

  requester_can_approve = false
  approvers             = var.deployment_approvers
}
Code Pack: Config
hth-azure-devops-5.02-runtime-parameters.yml View source on GitHub ↗
# azure-pipelines.yml
parameters:
  - name: deploymentKey
    type: string
    default: ''

variables:
  - group: production-config  # Non-secret config
  - name: secretKey
    value: ${{ parameters.deploymentKey }}

stages:
  - stage: Deploy
    jobs:
      - job: Deploy
        steps:
          - script: |
              # Use secret from parameter
              echo "##vso[task.setvariable variable=SECRET;issecret=true]$(secretKey)"

6. Monitoring & Detection

6.1 Enable Audit Logging

Profile Level: L1 (Crawl) NIST 800-53: AU-2, AU-3

Description

Configure and monitor Azure DevOps audit logs.

Rationale

Why This Matters:

  • Audit logs record service connection changes, permission grants, and pipeline modifications that signal compromise or insider activity
  • Exporting events to a SIEM enables alerting, correlation, and retention beyond the platform’s limited native window
  • Without monitoring, attacker actions like adding a backdoor service connection or escalating permissions go undetected
  • Audit trails are required evidence for SOC 2, ISO 27001, and incident investigation

Attack Prevented: Undetected compromise, insider threat, configuration tampering, delayed incident response

ClickOps Implementation

Step 1: Access Audit Logs

  1. Navigate to: Organization Settings → Auditing
  2. Review events:
    • Service connection changes
    • Permission changes
    • Pipeline modifications

Step 2: Stream Audit Events to Your SIEM

  1. Navigate to: Organization Settings → Auditing → Streams
  2. Create a stream pointed at your SIEM target. Streaming is the primary export path: it delivers events continuously, needs no code of your own, and it is what carries events past Azure DevOps’s limited native retention window. (Azure DevOps security overview)
  3. Confirm the stream is receiving events before you retire any polling job that currently covers this.

Step 3: Fallback — Scheduled REST Export

Where no stream target exists for your SIEM, fall back to polling the audit log API on a schedule. See the Code Pack below for a PowerShell script that exports audit logs via the Azure DevOps REST API with pagination support.

Detection Queries

See the DB Code Pack below for Azure Sentinel / Log Analytics KQL queries that detect service connection modifications, permission changes, and unusual build activity.

Code Implementation

Code Pack: Terraform
hth-azure-devops-6.1-enable-audit-logging.tf View source on GitHub ↗
# ---------------------------------------------------------------------------
# Azure DevOps audit logging is enabled by default at the organization
# level and accessed via Organization Settings > Auditing. The Terraform
# provider does not manage audit log configuration directly.
#
# This control creates a service hook to forward pipeline events to an
# external endpoint for SIEM integration. Service connection changes,
# permission changes, and pipeline modifications are captured.
#
# For full audit log export, use the Azure DevOps REST API:
#   GET https://auditservice.dev.azure.com/{org}/_apis/audit/auditlog
# ---------------------------------------------------------------------------

# Service hook: forward pipeline run events to external webhook (L2+)
resource "azuredevops_servicehook_permissions" "audit_hooks" {
  count = var.profile_level >= 2 ? 1 : 0

  project_id  = data.azuredevops_project.target.id
  principal   = azuredevops_group.security_reviewers.id
  permissions = {
    "ViewSubscriptions"   = "Allow"
    "EditSubscriptions"   = "Allow"
    "DeleteSubscriptions" = "Deny"
  }
}
Code Pack: API Script
hth-azure-devops-6.01-export-audit-logs.ps1 View source on GitHub ↗
$org = "your-org"
$continuationToken = ""

do {
    $response = Invoke-RestMethod `
        -Uri "https://auditservice.dev.azure.com/$org/_apis/audit/auditlog?api-version=7.1&continuationToken=$continuationToken" `
        -Headers $headers

    $response.decoratedAuditLogEntries | ForEach-Object {
        # Send to SIEM
        Send-ToSiem $_
    }

    $continuationToken = $response.continuationToken
} while ($continuationToken)
Code Pack: SIEM Queries
hth-azure-devops-6.01-detection-queries.kql View source on GitHub ↗
// Azure Sentinel / Log Analytics queries

// Detect service connection modifications
AzureDevOpsAuditing
| where OperationName contains "ServiceEndpoint"
| where OperationName contains "Modified" or OperationName contains "Created"
| project TimeGenerated, ActorUPN, OperationName, ProjectName, Data

// Detect pipeline permission changes
AzureDevOpsAuditing
| where OperationName contains "Security" or OperationName contains "Permission"
| project TimeGenerated, ActorUPN, OperationName, ProjectName, Data

// Detect unusual build activity
AzureDevOpsAuditing
| where OperationName == "Build.QueueBuild"
| summarize count() by ActorUPN, bin(TimeGenerated, 1h)
| where count_ > 50

7. Compliance Quick Reference

SOC 2 Mapping

Control ID Azure DevOps Control Guide Section
CC6.1 Azure AD + Conditional Access 1.1
CC6.2 Project permissions 1.2
CC8.1 Branch policies 4.1

NIST 800-53 Mapping

Control Azure DevOps Control Guide Section
IA-2(1) Azure AD MFA 1.1
IA-5 Service connection OIDC 2.1
CM-3 Branch policies 4.1
AU-2 Audit logging 6.1

Appendix A: Edition Compatibility

Control Basic Basic + Test Plans Azure DevOps Server
Azure AD
Conditional Access AD FS
Audit Logs
Workload Identity
Advanced Security Add-on Add-on Add-on

Appendix B: References

Official Microsoft Documentation:

API & Developer Tools:

Compliance Frameworks:

  • SOC 2 Type II (an Azure DevOps-specific attestation report is available separately from Microsoft under NDA)
  • ISO/IEC 27001:2022 — via Azure ISO 27001
  • SOC 1 Type II, ISO 27017, ISO 27018, CSA STAR, FedRAMP (High and Moderate)
  • PCI DSS, HIPAA, HITRUST

Security Incidents:

  • 2025 — Critical SSRF and CRLF Injection Vulnerabilities: Multiple critical vulnerabilities in Azure DevOps endpointproxy and Service Hooks components enabled DNS rebinding attacks and unauthorized access to internal services. Microsoft released patches and awarded a $15,000 bug bounty. (Legit Security Report)
  • May 2025 — CVE with CVSS 10.0 in Azure DevOps Server: Microsoft patched a maximum-severity vulnerability affecting Azure DevOps Server. (The Hacker News Report)
  • H1 2025 — 74 Service Incidents: Azure DevOps experienced 74 unique incidents from January-June 2025, including a 159-hour global Pipelines degradation in January. (GitProtect Report)

Changelog

Date Version Maturity Changes Author
2026-08-08 0.2.0 draft Currency pass. 1.1: Entra rename; third-party OAuth is now off by default for new organizations and does not govern Entra ID OAuth apps; replace the non-existent “restrict to managed keys” SSH setting with Validate SSH key expiration (default on); reframe alternate credentials as removed rather than disableable; reframe public projects as retired (new creation blocked, existing convert to private in 2027); add the Enable IP Conditional Access policy validation on non-interactive flows policy; convert Attack Scenario to Attack Prevented. 1.3: correct the three PAT policies to Microsoft Entra tenant policies (Organization settings → Microsoft Entra, Azure DevOps Administrator role, per-policy Entra allowlists, all default off), add the separate org-level PAT creation policy, add the default-on leaked-PAT auto-revocation as a do-not-disable callout, and add Entra-token/service-principal/managed-identity guidance for service accounts. 2.1: add the workload identity federation issuer retirement (Azure DevOps issuer deprecated 2026-07-01, retired 2027-07-01) with the conversion path and out-of-scope clouds, add resource-group scoping and the avoid-classic-connections rule, add a missing Attack Prevented line. 4.1: add Advanced Security pull-request status checks. 4.2: retitle to GitHub Advanced Security for Azure DevOps (also sold as GitHub Secret Protection / Code Security) and add enablement and triage steps. 6.1: native audit streaming becomes the primary SIEM path, REST export becomes the fallback. Appendix B: replace the redirecting security-best-practices link with the canonical security overview, add the newly cited docs, and remove the Service Trust Portal rows. Changelog order reconstructed and the duplicate 0.1.1 resolved (the 2026-06-29 row is renumbered 0.1.3). Not surveyed this pass: the sprint-by-sprint Azure DevOps release notes, and Tier 3/4 research. No CIS Azure DevOps Benchmark exists, so no benchmark mappings were added Claude Code (Opus 5)
2026-06-29 0.1.3 draft Add cheat-sheet Description and Rationale for all controls Claude Code (Opus 4.8)
2026-02-19 0.1.2 draft Migrate all remaining inline code to Code Packs (1.2, 2.1, 3.1, 3.3, 4.2, 5.2, 6.1); zero inline blocks Claude Code (Opus 4.6)
2026-02-19 0.1.1 draft Migrate inline PowerShell to CLI Code Packs (1.3, 2.2, 3.3, 6.1) Claude Code (Opus 4.6)
2025-12-14 0.1.0 draft Initial Azure DevOps hardening guide Claude Code (Opus 4.5)

Contributing

Found an issue or want to improve this guide?