Azure DevOps Hardening Guide
Microsoft DevOps security for pipelines, service connections, and artifact feeds
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 (Baseline): Essential controls for all organizations
- L2 (Hardened): Enhanced controls for security-sensitive environments
- L3 (Maximum Security): 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
- Authentication & Access Controls
- Service Connection Security
- Pipeline Security
- Repository Security
- Variable & Secret Management
- Monitoring & Detection
- Compliance Quick Reference
1. Authentication & Access Controls
1.1 Enforce Azure AD Authentication with Conditional Access
Profile Level: L1 (Baseline) 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 Scenario: Compromised service connection credentials enable infrastructure modification; variable group exposure leaks secrets to unauthorized pipelines.
ClickOps Implementation
Step 1: Configure Azure AD Connection
- Navigate to: Organization Settings → Azure Active Directory
- Connect to Azure AD tenant
- Enable: Only allow Azure AD users
Step 2: Create Conditional Access Policy (Azure AD)
- Navigate to: Azure Portal → Azure AD → Security → Conditional Access
- 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: Disable Alternate Authentication
- Navigate to: Organization Settings → Policies
- Disable:
- Third-party application access via OAuth: Disable or restrict
- SSH authentication: Restrict to managed keys
- Allow public projects: Disable
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
# ---------------------------------------------------------------------------
# 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 (Baseline) NIST 800-53: AC-3, AC-6
Description
Configure granular project permissions using Azure DevOps security groups.
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
- Navigate to: Project Settings → Permissions
- 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
- Create dedicated service accounts for pipelines
- Grant minimum permissions needed
- Do not add to Project Administrators
Code Implementation
Code Pack: Terraform
# ---------------------------------------------------------------------------
# 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 (Baseline) NIST 800-53: IA-5
Description
Restrict PAT creation and enforce expiration policies.
ClickOps Implementation
Step 1: Configure Organization PAT Policy
- Navigate to: Organization Settings → Policies
- Configure:
- Restrict creation of full-scoped PATs: Enable
- Maximum PAT lifetime: 90 days
- Restrict global PATs: Enable
Step 2: 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
# ---------------------------------------------------------------------------
# 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: CLI Script
$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 (Baseline) - 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
ClickOps Implementation
Step 1: Create Workload Identity Federation Service Connection
- Navigate to: Project Settings → Service connections
- Click New service connection → Azure Resource Manager
- Select: Workload Identity federation (automatic)
- Configure:
- Subscription: Target subscription
- Service connection name: Descriptive name
- Grant access to all pipelines: Disable
Step 2: Migrate Existing Service Connections
- Identify connections using stored credentials
- Create new OIDC-based connections
- Update pipeline references
- Delete old credential-based connections
Step 3: Restrict Service Connection Access
- Navigate to: Service connection → Security
- Configure:
- Pipeline permissions: Specific pipelines only
- User permissions: Administrators only
- Allow all pipelines: Disable
Code Implementation (Pipeline)
Code Pack: Terraform
# ---------------------------------------------------------------------------
# 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: CLI Script
# 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 (Baseline) NIST 800-53: IA-5(1)
Description
Audit service connections with stored credentials and implement rotation schedule.
ClickOps Implementation
Step 1: Audit Service Connections
- Navigate to: Project Settings → Service connections
- 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
# ---------------------------------------------------------------------------
# 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: CLI Script
# 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 (Hardened) NIST 800-53: CM-3
Description
Require approval for pipeline use of sensitive service connections.
ClickOps Implementation
Step 1: Configure Approvals and Checks
- Navigate to: Service connection → Approvals and checks
- 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
# ---------------------------------------------------------------------------
# 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 (Baseline) NIST 800-53: CM-7
Description
Configure secure YAML pipeline practices and restrict classic pipelines.
ClickOps Implementation
Step 1: Disable Classic Pipelines (L2)
- Navigate to: Organization Settings → Pipelines → Settings
- Disable:
- Disable creation of classic build pipelines: Enable
- Disable creation of classic release pipelines: Enable
Step 2: Require YAML Pipeline Reviews
- Navigate to: Project Settings → Repositories → Policies
- 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
# ---------------------------------------------------------------------------
# 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 (Baseline) NIST 800-53: AC-3
Description
Restrict pipeline access to resources and require approvals for production.
ClickOps Implementation
Step 1: Configure Environment Approvals
- Navigate to: Pipelines → Environments → production
- Add approvals and checks:
- Approvers: Required for deployment
- Branch control: Only main branch
- Business hours: Optional restriction
Step 2: Configure Pipeline Permissions
- Navigate to: Pipeline → Security
- Configure:
- Pipeline permissions: Specific users/groups
- Queue builds: Restricted to authorized users
Code Implementation
Code Pack: Terraform
# ---------------------------------------------------------------------------
# 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 (Baseline) NIST 800-53: SC-7
Description
Configure agent pools with appropriate security controls.
ClickOps Implementation
Step 1: Create Tiered Agent Pools
- Azure Pipelines – Microsoft-hosted, ephemeral (built-in)
- Development-Agents – self-hosted, lower trust
- Production-Agents – self-hosted, restricted access
- Security-Agents – isolated, scanning tools only
Step 2: Configure Pool Permissions
- Navigate to: Organization Settings → Agent pools
- 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
# ---------------------------------------------------------------------------
# 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
# 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 (Baseline) NIST 800-53: CM-3
Description
Implement branch policies to enforce code review and prevent direct pushes.
ClickOps Implementation
Step 1: Configure Protected Branches
- Navigate to: Repos → Branches → main → Branch policies
- 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
- Add path filters for sensitive directories:
azure-pipelines.yml: Require security team reviewterraform/: Require platform team review
Code Implementation
Code Pack: Terraform
# ---------------------------------------------------------------------------
# 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 Credential Scanning
Profile Level: L1 (Baseline) NIST 800-53: RA-5
Description
Enable Microsoft Security DevOps to detect secrets in repositories.
Implementation
Code Pack: Terraform
# ---------------------------------------------------------------------------
# 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.
#
# Microsoft Security DevOps pipeline scanning (MicrosoftSecurityDevOps@1)
# is configured via YAML pipelines, not Terraform.
# ---------------------------------------------------------------------------
# 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: CLI Script
# 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 (Baseline) NIST 800-53: SC-28
Description
Configure variable groups with appropriate security controls.
ClickOps Implementation
Step 1: Create Environment-Specific Variable Groups
- Navigate to: Pipelines → Library → Variable groups
- Create groups:
production-secrets(linked to Key Vault)staging-secretsshared-config
Step 2: Link to Azure Key Vault
- Create variable group linked to Key Vault
- Configure:
- Azure subscription: Service connection
- Key vault name: Production vault
- Secrets: Select required secrets
Step 3: Configure Variable Group Permissions
- Navigate to: Variable group → Security
- Configure:
- Pipeline permissions: Specific pipelines only
- User permissions: Administrators only
Code Implementation
Code Pack: Terraform
# ---------------------------------------------------------------------------
# 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 (Hardened) 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.
Code Implementation
Code Pack: Terraform
# ---------------------------------------------------------------------------
# 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: CLI Script
# 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 (Baseline) NIST 800-53: AU-2, AU-3
Description
Configure and monitor Azure DevOps audit logs.
ClickOps Implementation
Step 1: Access Audit Logs
- Navigate to: Organization Settings → Auditing
- Review events:
- Service connection changes
- Permission changes
- Pipeline modifications
Step 2: Export to SIEM
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
# ---------------------------------------------------------------------------
# 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: CLI Script
$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: DB Query
// 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:
- Microsoft Service Trust Portal (SOC reports, compliance documentation)
- Azure DevOps Documentation
- Security Best Practices
- Workload Identity Federation
- Audit Logging
- Azure Compliance Documentation
API & Developer Tools:
- Azure DevOps REST API
- Azure DevOps CLI Extension
- Azure DevOps SDKs (.NET, Python, Node.js)
- GitHub Organization (Microsoft)
Compliance Frameworks:
- SOC 2 Type II (Azure DevOps specific attestation report available separately) — via Service Trust Portal
- 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 |
|---|---|---|---|---|
| 2025-12-14 | 0.1.0 | draft | Initial Azure DevOps hardening guide | Claude Code (Opus 4.5) |
| 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) |