Azure DevOps API Exposure Hardening: Securing Against Unauthenticated Information Disclosure
Problem
CVE-2026-42826, published 7 May 2026 with a CVSS base score of 10.0, demonstrated that Azure DevOps can serve pipeline configuration, variable group contents, and build artefacts to unauthenticated HTTP requests under certain conditions. Microsoft patched the specific bypass within 48 hours, but the CVE is best understood as a forcing function that exposed years of accumulated configuration debt rather than a narrow code defect.
The vulnerability class is unauthenticated remote information disclosure requiring no user interaction. A remote attacker sends a crafted request to the Azure DevOps REST API and receives a JSON response containing pipeline YAML, variable group metadata (including names of secret variables), service connection identifiers, and build log fragments. No credentials are required when the target project is configured for public visibility — or when a specific internal API endpoint fails to enforce authentication checks before returning data.
The broader attack surface that CVE-2026-42826 exposed includes:
- Public project visibility turned on by default in many organisations. Any Azure DevOps organisation created before mid-2024 may have the “allow public projects” toggle enabled, permitting any team member to set a project to public. Public projects expose repos, pipelines, artefacts, and work items to the unauthenticated internet.
- Overly scoped Personal Access Tokens (PATs). PATs created with full-access scope and multi-year expiry are common. A leaked PAT — in a committed
.envfile, a Slack message, or a build log — gives an attacker complete read/write access to every resource in the organisation. - Service connections using “Allow all pipelines”. This default setting means any pipeline in the project can use a service connection that might hold credentials for a production Azure subscription, a Docker registry, or a Kubernetes cluster.
- Inline secrets in variable groups not marked secret. Variable values that are not marked as secret are visible in the UI, returned by the API, and printed to build logs. A read-only attacker can harvest them without triggering any alerts.
Target systems include both Azure DevOps Services (the cloud-hosted SaaS offering) and Azure DevOps Server 2022 and 2025 (on-premises). The REST API surface is identical in both deployment models.
Threat Model
Four distinct attack paths lead to information disclosure or production access via Azure DevOps:
Path 1 — Unauthenticated CVE-2026-42826 class exploit. An attacker with no credentials sends requests to Azure DevOps API endpoints. If the organisation has public projects enabled, or if an authentication bypass vulnerability exists, the attacker receives pipeline YAML, variable group data, and build logs. The attacker maps internal infrastructure from YAML (azureSubscription, kubernetesServiceConnection, dockerRegistryServiceConnection fields), identifies service connection names as targets for further exploitation, and extracts non-secret variable values.
Path 2 — Leaked PAT with full-access scope. A developer commits a .env file containing AZURE_DEVOPS_PAT=... to a public GitHub repository. An automated secret scanner finds it within minutes. The attacker uses the PAT to call the Azure DevOps REST API: enumerate all projects, clone all repositories, list variable groups, read build logs, and trigger pipelines. A full-access PAT is functionally equivalent to the user’s own Azure DevOps session.
Path 3 — Public project pipeline YAML reconnaissance. An attacker browses a public Azure DevOps project and reads azure-pipelines.yml files in the repository. The YAML references service connections by name and variable groups by name. Even without accessing the service connection credentials directly, the attacker learns the organisation’s Azure subscription structure, deployment targets, registry URLs, and environment names. This information is sufficient to plan a more targeted attack.
Path 4 — Compromised service connection. An attacker who has obtained Azure DevOps read access (via any of the above paths) cannot directly read service connection secrets through the API. However, they can trigger a pipeline that uses the service connection and exfiltrate the effective credentials during the build by capturing the Azure CLI auth token or environment variables injected by the AzureCLI@2 task. Once the attacker has the service principal credentials, they can access production Azure resources directly, bypassing Azure DevOps entirely.
Configuration and Implementation
1. Disable Public Project Visibility
The most impactful single control is disabling the ability to create or maintain public projects.
Navigate to Organisation Settings → Security → Policies and set Allow public projects to Off. This is an organisation-level toggle; disabling it forces all existing public projects to private and prevents future public project creation.
Audit current project visibility across the organisation using the Azure DevOps REST API:
# Set your organisation name
ORG="your-org-name"
PAT="your-auditor-pat" # Needs Project Read scope
# List all projects and their visibility
curl -s \
-u ":${PAT}" \
"https://dev.azure.com/${ORG}/_apis/projects?api-version=7.1&\$top=200" \
| jq -r '.value[] | "\(.name)\t\(.visibility)"'
To change a specific project from public to private via the CLI:
az devops project update \
--project "my-public-project" \
--org "https://dev.azure.com/${ORG}" \
--visibility private
Run this audit as a scheduled pipeline job and alert when any project visibility is set to public. New projects default to private when the organisation policy is disabled, but the audit catches legacy projects and detects policy drift.
2. PAT Scoping and Lifecycle Management
Replace all full-access PATs with minimal-scope PATs tied to specific use cases. Azure DevOps PATs support fine-grained scopes including Code (Read), Build (Read & execute), Packaging (Read), and Service Connections (Read & query).
Create a new scoped PAT:
- Navigate to User Settings → Personal Access Tokens → New Token
- Set Expiration to 90 days (maximum: 1 year)
- Select Custom defined for scope
- Grant only the permissions required by the consuming system
Enumerate all active PATs in the organisation via the REST API (requires an organisation admin PAT with Policy Read scope):
# List all active PATs across the org (requires admin PAT)
curl -s \
-u ":${ADMIN_PAT}" \
"https://vssps.dev.azure.com/${ORG}/_apis/tokens/pats?api-version=7.1-preview.1" \
| jq -r '.patTokens[] | "\(.displayName)\t\(.scope)\t\(.validTo)\t\(.authorizationId)"'
Identify PATs with vso.default or vso.allscopes scope (full access) and PATs with validTo dates more than 90 days in the future:
curl -s \
-u ":${ADMIN_PAT}" \
"https://vssps.dev.azure.com/${ORG}/_apis/tokens/pats?api-version=7.1-preview.1" \
| jq -r '.patTokens[] | select(.scope == "vso.allscopes" or (.validTo | fromdateiso8601) > (now + 7776000)) | "\(.displayName)\t\(.scope)\t\(.validTo)"'
For automation pipelines (deployment scripts, scheduled tasks, integrations), replace PATs with Azure AD service principals or workload identity federation. PATs authenticate as a human user account; when the user leaves the organisation, all their PATs are invalidated, breaking automation. Service principals have an independent lifecycle tied to the pipeline, not the person.
3. Service Connection Hardening
Service connections are the bridge between Azure DevOps pipelines and external systems. The default “Allow all pipelines” setting is the single most dangerous misconfiguration after public project visibility.
Disable “Allow all pipelines” on every service connection:
# List all service connections in a project
az devops service-endpoint list \
--project "my-project" \
--org "https://dev.azure.com/${ORG}" \
--output table
# Update a service connection to disable "allow all pipelines"
# This must be done via the REST API; the az CLI does not expose this setting directly
SC_ID="your-service-connection-id"
PROJECT_ID="your-project-id"
curl -s -X PATCH \
-u ":${ADMIN_PAT}" \
-H "Content-Type: application/json" \
"https://dev.azure.com/${ORG}/${PROJECT_ID}/_apis/serviceendpoint/endpoints/${SC_ID}?api-version=7.1" \
-d '{"isShared": false, "serviceEndpointProjectReferences": [{"projectReference": {"id": "'${PROJECT_ID}'"}, "name": "prod-azure-connection"}]}'
After disabling “Allow all pipelines”, configure explicit pipeline authorisations via Project Settings → Service Connections → [Connection Name] → Security → Pipeline permissions → Restrict permission and grant access only to the specific pipelines that need it.
Enable approvals and checks on service connections:
In Project Settings → Service Connections → [Connection Name] → Approvals and checks, add:
- Approvals: require a named approver before a pipeline can use this connection for a deployment
- Branch control: restrict usage to pipelines running from
mainorrefs/heads/release/*branches only - Business hours check: restrict deployments to defined time windows
# azure-pipelines.yml — gate a production deployment stage
stages:
- stage: DeployProduction
dependsOn: DeployStaging
jobs:
- deployment: DeployToProduction
environment: production # environment has approval gates configured
strategy:
runOnce:
deploy:
steps:
- task: AzureCLI@2
inputs:
azureSubscription: 'prod-azure-connection' # service connection
scriptType: bash
scriptLocation: inlineScript
inlineScript: |
az webapp deploy \
--name my-app \
--resource-group my-rg \
--src-path $(Pipeline.Workspace)/drop/app.zip
4. Variable Group Security
All sensitive values stored in variable groups must be marked as secret. A variable marked as secret is masked in build logs and is not returned by the REST API in plaintext.
Mark existing variables as secret:
Navigate to Pipelines → Library → [Variable Group] → [Variable] → Lock icon to toggle secret status. Variables already stored without the secret flag must be re-entered; the existing plaintext value cannot be retroactively hidden.
Migrate to Azure Key Vault-linked variable groups to remove secret storage from Azure DevOps entirely:
- Create a Key Vault in Azure and store secrets there
- In Azure DevOps, create a service connection to the Key Vault (use a managed identity or service principal with
Key Vault Secrets UserRBAC only) - Link the variable group to the Key Vault:
az pipelines variable-group create \
--name "production-secrets" \
--project "my-project" \
--org "https://dev.azure.com/${ORG}" \
--authorize true \
--variables "placeholder=unused" \
--description "Key Vault linked - do not add inline secrets"
# Then link to Key Vault via the UI:
# Pipelines → Library → production-secrets → Link secrets from Azure Key Vault
# Select the Key Vault service connection and add individual secrets
Reference Key Vault-linked variables in pipelines exactly as inline variables — the pipeline engine retrieves the current secret value at job start time. If the secret is rotated in Key Vault, the next pipeline run automatically uses the new value.
5. Pipeline YAML Hardening
Pipeline YAML files are source-controlled and often reviewed less carefully than application code. Common mistakes that lead to credential exposure:
Never echo secret variables:
# BAD — do not do this
- script: echo "The password is $(DB_PASSWORD)"
# GOOD — Azure DevOps masks the value in logs, but avoid echoing at all
- script: |
# Secret is available as $DB_PASSWORD in the environment
# but never print it
./deploy.sh
env:
DB_PASSWORD: $(DB_PASSWORD)
Mark runtime secrets as secret using the logging command:
- script: |
# Generate a temporary token and mark it secret immediately
TEMP_TOKEN=$(./generate-token.sh)
echo "##vso[task.setvariable variable=TEMP_TOKEN;issecret=true]${TEMP_TOKEN}"
displayName: 'Generate and mask temporary token'
- script: |
# TEMP_TOKEN is now masked in subsequent steps
curl -H "Authorization: Bearer ${TEMP_TOKEN}" https://api.example.com/deploy
env:
TEMP_TOKEN: $(TEMP_TOKEN)
Do not embed credentials in YAML:
# BAD — hardcoded credential in pipeline YAML (visible in repo history forever)
- script: |
docker login myregistry.azurecr.io -u myuser -p MyP@ssword123
# GOOD — use a service connection; credentials never appear in YAML
- task: Docker@2
inputs:
containerRegistry: 'my-acr-connection'
command: 'login'
6. Azure AD Conditional Access for Azure DevOps
Azure DevOps respects Azure AD Conditional Access policies when users authenticate via Azure AD. Configure a policy targeting the Azure DevOps cloud application:
- In the Azure portal, navigate to Azure AD → Security → Conditional Access → New policy
- Set Users to all users (or the Azure DevOps users group)
- Set Cloud apps to Azure DevOps
- Set Conditions → Device platforms to include all platforms
- Set Grant controls:
- Require multi-factor authentication
- Require compliant device (Intune-enrolled)
- Set Session controls:
- Sign-in frequency: 8 hours
- Persistent browser session: Never persistent
Conditional Access applies to interactive authentication but not to PAT-authenticated API calls. PATs authenticate as the issuing user but bypass the session-level conditional access check. This is why PAT scoping and expiry are mandatory controls in addition to conditional access, not alternatives to it.
For service accounts used by automation, exclude them from the conditional access policy by group assignment, then enforce compensating controls: no interactive login, PATs with minimum scope only, monitored via audit log.
7. Network Restriction: IP Allowlisting
Azure DevOps Services supports IP-based allowlisting at the organisation level. Navigate to Organisation Settings → Security → Policies → IP allowlist and add the CIDR ranges for:
- Corporate VPN egress IP ranges
- Office network egress IP ranges
- Azure DevOps hosted agent IP ranges (required if using Microsoft-hosted runners)
- Self-hosted runner egress IP ranges
# The IP allowlist is managed via the Azure DevOps REST API
# Add an IP range to the allowlist (requires organisation admin PAT)
curl -s -X PATCH \
-u ":${ADMIN_PAT}" \
-H "Content-Type: application/json" \
"https://dev.azure.com/${ORG}/_apis/organizationpolicy/policies/dd4e3a2e-d80f-4cf8-8b67-f9c7b4c4a8e3?api-version=7.1-preview.1" \
-d '{
"isEnabled": true,
"value": "10.0.0.0/8,203.0.113.0/24"
}'
Microsoft-hosted agent IP ranges are published at https://www.microsoft.com/en-us/download/details.aspx?id=56519 and change weekly. Maintain these dynamically or switch to self-hosted agents on a known IP range.
8. Audit Log Monitoring
Enable and ship Azure DevOps audit logs to Azure Monitor for SIEM integration and alerting.
Enable audit log streaming:
In Organisation Settings → Audit → Streams, add an Azure Monitor Log Analytics workspace as a stream destination. Audit events are delivered in near-real-time.
Create alert rules in Azure Monitor for the following high-priority event categories:
# KQL — Alert when a PAT is created without expiry (or with expiry > 90 days)
AzureDevOpsAuditing
| where OperationName == "Token.PatCreateEvent"
| extend ExpiryDate = todatetime(Data.validTo)
| where ExpiryDate > now() + 90d or isnull(ExpiryDate)
| project TimeGenerated, ActorDisplayName, Data.displayName, ExpiryDate
# KQL — Alert on service connection permission changes
AzureDevOpsAuditing
| where OperationName in (
"ServiceEndpoint.EndpointModified",
"ServiceEndpoint.EndpointPermissionsUpdated"
)
| project TimeGenerated, ActorDisplayName, Data.Name, OperationName
# KQL — Alert when any project is set to public visibility
AzureDevOpsAuditing
| where OperationName == "Project.UpdateProjectVisibility"
| extend NewVisibility = tostring(Data.newVisibility)
| where NewVisibility == "public"
| project TimeGenerated, ActorDisplayName, Data.ProjectName
# KQL — Alert on high-volume unauthenticated API requests (potential scanning)
AzureDevOpsAuditing
| where OperationName == "Security.AuthenticationFailure"
| summarize count() by bin(TimeGenerated, 5m), IpAddress
| where count_ > 50
| project TimeGenerated, IpAddress, count_
Route high-priority alerts to PagerDuty or your SIEM. Low-priority audit events (normal PAT creation, pipeline runs) can be retained in Log Analytics for 90 days for forensic purposes.
Expected Behaviour
The following table maps common exposure scenarios to what an attacker can see before and after hardening.
| Scenario | Information Visible to Attacker (Unhardened) | Hardened State |
|---|---|---|
| Public project enabled | All pipeline YAML, repo contents, build logs, work items, wiki — no credentials required | Project set to private; org policy blocks future public projects |
| Leaked full-scope PAT | All repos, all variable groups (name + value of non-secret variables), all service connection names, ability to trigger pipelines and read all build output | PAT has Code (Read) scope only; 90-day expiry; cannot access variable groups or trigger pipelines |
| Service connection with “Allow all pipelines” | Any pipeline in the project can use the connection; an attacker who can create/modify a pipeline can exfiltrate the effective credentials | “Allow all pipelines” disabled; only 2 named pipelines authorised; branch control check enforces main-only |
| Variable group with unmasked secret | Secret value visible in UI, returned by REST API GET /variablegroups, printed to build log on echo |
Secret marked as secret in the variable group; value masked in logs and API responses; or migrated to Key Vault-linked group |
| No conditional access policy | PAT or session tokens usable from any IP, any device, any country | Conditional access requires MFA and compliant device for Azure AD sessions; PATs restricted by IP allowlist |
| No audit log shipping | Attacker exfiltrates data for days or weeks before detection | Audit log streamed to Log Analytics; alert fires within 5 minutes of anomalous PAT creation or service connection change |
Trade-offs
| Control | Security Benefit | Operational Cost | Recommendation |
|---|---|---|---|
| IP allowlisting | Blocks unauthenticated access from unexpected networks; limits blast radius of leaked PAT | Remote developers, contractors, and travelling employees cannot access Azure DevOps without VPN; Microsoft-hosted agent IP ranges must be maintained weekly | Enable for organisations with a fixed VPN; pair with split-tunnel VPN for remote workers |
| Short PAT expiry (90 days) | Limits the window a leaked PAT is usable; forces regular rotation awareness | Automation pipelines that embed PATs in configuration must be updated every 90 days; risk of pipeline failure on expiry | Automate PAT rotation with a managed service account; prefer workload identity federation over PATs for automation |
| Service connection approvals | Prevents unauthorised pipeline runs from using production credentials; creates an audit trail of approval decisions | Adds human latency (minutes to hours) to every deployment; approval bottleneck if approver is unavailable | Apply to production service connections only; use automated checks (branch control, business hours) for staging |
| Key Vault-linked variable groups | Secrets never stored in Azure DevOps; centrally audited; rotated independently of pipeline configuration | Every pipeline job that reads secrets makes a Key Vault API call; pipeline fails if Key Vault is unavailable or the service connection token expires | Use for all production secrets; ensure Key Vault has zone-redundant replication; monitor Key Vault availability separately |
Failure Modes
| Failure | Trigger | Impact | Detection and Recovery |
|---|---|---|---|
| PAT rotation automation breaks | Automation pipeline that rotates PATs fails silently; old PAT expires; new PAT not saved to Key Vault or secret store | All downstream pipelines using that PAT fail with TF400813: Resource not available for anonymous access |
Monitor pipeline success rate; alert on consecutive failures; maintain a break-glass PAT in Key Vault with a separate expiry cycle |
| Key Vault service connection expires | The Azure AD service principal backing the Key Vault service connection has a client secret that expires; or managed identity assignment is removed | All pipelines that read from Key Vault-linked variable groups fail at job start with Azure Key Vault: GetSecret request failed |
Set Azure AD app registration secret expiry alerts 30 days before expiry; prefer managed identity to eliminate secret rotation for this connection |
| Conditional access policy locks out CI service accounts | A new conditional access policy applies to all users including service accounts used for self-hosted agent registration or API polling | Self-hosted agents deregister from the pool; scheduled pipelines fail to start | Test conditional access policies against a subset of users first; exclude service account group from conditional access; use workload identity where possible |
| Audit log stream not configured or drops events | Log Analytics stream is not set up; or stream is interrupted by network error; or the stream destination workspace has data ingestion throttled | A breach or insider threat goes undetected for the retention window; forensic investigation is impossible | Monitor the audit stream health via a synthetic check: write a known audit event and verify it appears in Log Analytics within 15 minutes; alert on stream gaps |
Related Articles
- Azure DevOps and Azure Pipelines Security Hardening — covers YAML pipeline authoring, service connection scoping, workload identity federation, and agent pool isolation
- Secret Management in CI/CD Pipelines — Vault, SOPS, and OIDC federation as alternatives to static credentials
- GitHub Actions Supply Chain Hardening — pinning actions, reusable workflow security, and third-party action vetting
- Secret Rotation Automation — automating credential rotation across cloud providers and CI/CD systems
- Audit Logging Architecture — designing tamper-evident, queryable audit log pipelines for compliance and detection