Secure Azure DevOps with cnspec
Scan Azure DevOps organizations, projects, and Git repositories against security and compliance best practices with cnspec.
Azure DevOps holds the source code your teams ship and the pipelines that deploy it, so a gap in how branches are protected or who can push to them reaches production. cnspec evaluates the projects in an Azure DevOps organization, the branch policies and repository permissions that protect each Git repository, the checks that guard pipeline environments, the service hooks that send repository events outside Azure DevOps, and the state and alerts of GitHub Advanced Security for Azure DevOps.
If you're new to cnspec, start with the Quickstart. For an overview of every SaaS service cnspec can scan, see the SaaS scanning overview.
Prerequisites
To scan Azure DevOps with cnspec, you must have:
- cnspec installed on your workstation
- An Azure DevOps Services organization (
dev.azure.com). Azure DevOps Server, the on-premises product, isn't supported. - A personal access token, or a Microsoft Entra service principal that's a user of the organization
Authenticate
cnspec reads Azure DevOps through read-only REST queries. It accepts a personal access token or a Microsoft Entra service principal.
Personal access token
In the Azure DevOps portal, go to User settings > Personal access tokens and create a token for the organization you want to scan. Give it these scopes:
- Code (Read)
- Project and Team (Read)
Pass the token with --token, or set it in the AZURE_DEVOPS_TOKEN environment variable.
Microsoft Entra service principal
To scan with a service principal, first add it to the organization in Organization settings > Users. Azure DevOps rejects every request from a service principal that isn't a user of the organization. Then add it to each project you want to scan. Azure DevOps hides the projects a credential has no access to, so a service principal that belongs to the organization but to none of its projects sees an empty organization.
Pass the service principal with --tenant-id, --client-id, and --client-secret, or set them in the AZURE_TENANT_ID, AZURE_CLIENT_ID, and AZURE_CLIENT_SECRET environment variables.
When you pass --tenant-id or --client-id, cnspec uses the service principal even if a token is also set. Otherwise a token takes priority over service principal environment variables, so a tenant ID you exported for another tool doesn't override your token.
Permissions for every check
A credential that reads code and projects is enough to discover and scan repositories. Some fields need more. For example, webhooks needs the View subscriptions permission on the project, which the Readers group lacks by default.
A credential that can't read a field reports it as a forbidden error rather than an empty result, so a check never passes on data cnspec wasn't allowed to read. If a check reports a forbidden error, grant the credential read access to that part of the project or repository.
Environment variables
You can export the connection details once and reuse them across commands:
export AZURE_DEVOPS_TOKEN=YOUR_TOKENWhen these are set, you can omit the matching flags from the commands below.
Connection options
| Option | Description |
|---|---|
--token | Azure DevOps personal access token (also AZURE_DEVOPS_TOKEN) |
--tenant-id | Microsoft Entra tenant ID of the service principal (also AZURE_TENANT_ID) |
--client-id | Microsoft Entra client ID of the service principal (also AZURE_CLIENT_ID) |
--client-secret | Client secret of the service principal (also AZURE_CLIENT_SECRET) |
--repos | Only include repositories that match these comma-separated <project>/<repo> patterns |
--repos-exclude | Leave out repositories that match these comma-separated <project>/<repo> patterns |
--discover | Which assets to discover: auto, organization, repos, terraform, k8s-manifests, or all |
The organization argument accepts the organization name, its dev.azure.com/<name> address, or its <name>.visualstudio.com address, with or without https://.
Verify with a quick Azure DevOps check
Confirm that cnspec can reach your organization by opening a cnspec shell on the organization alone:
cnspec shell azuredevops org lunalectric --token YOUR_TOKEN --discover organizationcnspec> azuredevops.organization { name id deploymentType }
azuredevops.organization: {
name: "lunalectric"
id: "6f1c2b9e-4d7a-4b1e-9a3c-2e8f5d0c7a14"
deploymentType: "hosted"
}If cnspec connects and reports hosted as the deployment type, you're ready to scan.
Scan Azure DevOps
Scan an organization and every repository in it:
cnspec scan azuredevops org lunalectric --token YOUR_TOKENScan a single repository:
cnspec scan azuredevops repo lunalectric/platform/payments-api --token YOUR_TOKENWhen a scan completes, cnspec prints a summary of all the checks it ran, grouped by policy, along with a risk score from 0 (no risk) to 100 (highest risk). Failed checks include remediation guidance to help you fix issues. To learn more about reading scan results, read Understand cnspec Results.
Mondoo doesn't yet ship an out-of-the-box Azure DevOps policy, so use the checks below as a starting point and create your own policies to meet your specific requirements. A policy targets repository assets with asset.platform == "azuredevops-repo" and the organization asset with asset.platform == "azuredevops-org".
Choose what to discover
An organization scan discovers the organization and each of its repositories by default. Use --discover to change that:
| Target | What cnspec discovers |
|---|---|
auto | The organization and its repositories (the default) |
organization | The organization asset alone |
repos | One asset for each repository |
terraform | A Terraform asset for each repository that holds a .tf file |
k8s-manifests | A Kubernetes manifest asset for each repository that holds YAML files |
all | Every target above |
For example, to scan the Terraform code in your repositories along with the repositories themselves:
cnspec scan azuredevops org lunalectric --token YOUR_TOKEN --discover repos,terraformcnspec skips files under a hidden path, such as .github or .azure, when it looks for Terraform and Kubernetes files.
Only repositories that have commits and aren't disabled become assets in an organization scan. cnspec reports the empty and disabled repositories it skips, and it reports any project whose repositories the credential can't list. A repo scan still scans the repository you name when it's empty or disabled, but doesn't discover Terraform or Kubernetes assets in it.
Filter repositories
Narrow a scan with --repos and --repos-exclude. Each takes a comma-separated list of <project>/<repo> patterns, matched without regard to letter case. cnspec keeps the repositories that match the include list, then removes the ones that match the exclude list:
cnspec scan azuredevops org lunalectric --token YOUR_TOKEN --repos "platform/*" --repos-exclude "platform/archive-*"A * doesn't cross the slash, so platform/* is every repository in the platform project and */infra-* is every repository whose name starts with infra- in any project. Use ** to match across the slash. A pattern without a slash, such as infra-*, matches nothing.
Setting either filter leaves the organization asset out of the scan, since a filter asks for specific repositories.
Explore and test checks interactively
Open a cnspec shell to discover resources and try out checks. A shell connects to one asset, so ask for the organization alone:
cnspec shell azuredevops org lunalectric --token YOUR_TOKEN --discover organizationList the projects in the organization
cnspec> azuredevops.organization.projects { name visibility state lastUpdateTime }List the repositories cnspec can scan
cnspec> azuredevops.organization.repositories.where(status == "ready") { fullName defaultBranch }Find the repositories a scan skips, and why
status is ready, empty (no commits yet), or disabled:
cnspec> azuredevops.organization.repositories.where(status != "ready") { fullName status }Find projects the credential can't fully read
unreadableProjects names the projects the credential can see but whose repositories it can't list. Azure DevOps leaves a project the credential has no access to out of projects entirely, so compare projects with the projects you expect to find:
cnspec> azuredevops.organization.unreadableProjectsReview the protection of each default branch
protectionRules summarizes the enabled, blocking branch policies that apply to a branch, together with the repository permissions that decide who can force push or bypass those policies:
cnspec> azuredevops.organization.repositories.where(status == "ready") {
fullName
branches.where(isDefault == true) {
name
isProtected
protectionRules {
requiredApprovingReviewCount
requireCodeOwnerReviews
requiredConversationResolutionEnabled
requiredStatusChecksEnabled
allowForcePushesEnabled
enforceAdminsEnabled
}
}
}List the branch policies of a repository
Open a shell on one repository to query it directly:
cnspec shell azuredevops repo lunalectric/platform/payments-api --token YOUR_TOKENcnspec> azuredevops.repository.policies { typeName enabled blocking scope settings }Review the checks that guard pipeline environments
cnspec> azuredevops.organization.projects {
name
environments {
name
protectionRules { type preventSelfReview minRequiredApprovers }
}
}List service hooks that send repository events outside Azure DevOps
cnspec reports the scheme and host of each target address but leaves out the full address, since its path or query can carry a secret:
cnspec> azuredevops.repository.webhooks { eventType consumerId host isHttps active }Review Advanced Security alerts
cnspec> azuredevops.repository.advancedSecurity.alerts.where(state == "active") { alertType severity title gitRef }Find pipeline definitions
pipelineFiles finds every azure-pipelines.yml or azure-pipelines.yaml at any depth, and every YAML file under a .azure-pipelines or .azuredevops folder at the root:
cnspec> azuredevops.repository.pipelineFiles { path }Example security checks
Run these checks in a shell on a repository, or use them in a policy that targets repository assets.
Ensure the default branch is protected
A branch is protected when an enabled, blocking branch policy applies to it:
cnspec> azuredevops.repository.branches.where(isDefault == true).all(isProtected == true)
[ok] value: trueEnsure pull requests into the default branch need at least two approvals
cnspec> azuredevops.repository.branches.where(isDefault == true).all(protectionRules.requiredApprovingReviewCount >= 2)
[ok] value: trueEnsure a required reviewer policy applies to the default branch
A required reviewers policy is the Azure DevOps counterpart of code owner review:
cnspec> azuredevops.repository.branches.where(isDefault == true).all(protectionRules.requireCodeOwnerReviews == true)
[ok] value: trueEnsure every pull request comment is resolved before completion
cnspec> azuredevops.repository.branches.where(isDefault == true).all(protectionRules.requiredConversationResolutionEnabled == true)
[ok] value: trueEnsure a build must pass before a pull request completes
cnspec> azuredevops.repository.branches.where(isDefault == true).all(protectionRules.requiredStatusChecksEnabled == true)
[ok] value: trueEnsure only service identities can force push
A force push rewrites history that reviewers already approved. allowForcePushesEnabled is true when a user or group other than a build or release service identity holds the Force push permission on the repository:
cnspec> azuredevops.repository.branches.where(isDefault == true).all(protectionRules.allowForcePushesEnabled == false)
[ok] value: trueEnsure no one can bypass branch policies
Azure DevOps has no setting that applies branch policies to administrators too. enforceAdminsEnabled is true when no user or group other than a build or release service identity can bypass the policies when pushing or completing a pull request:
cnspec> azuredevops.repository.branches.where(isDefault == true).all(protectionRules.enforceAdminsEnabled == true)
[ok] value: trueEnsure Advanced Security is turned on
cnspec> azuredevops.repository.advancedSecurity.enabled == true
[ok] value: trueEnsure no critical or high severity alert is open
When Advanced Security is off, alerts is null, so this check doesn't apply rather than passing on an empty list:
cnspec> azuredevops.repository.advancedSecurity.alerts.none(state == "active" && (severity == "critical" || severity == "high"))
[ok] value: trueEnsure no leaked secret is open
cnspec> azuredevops.repository.advancedSecurity.alerts.none(state == "active" && alertType == "secret")
[ok] value: trueEnsure every service hook sends events over HTTPS
cnspec> azuredevops.repository.webhooks.all(isHttps == true)
[ok] value: trueEnsure the repository has a security policy
securityFile looks for SECURITY.md at the root, in docs, or in .github:
cnspec> azuredevops.repository.securityFile.exists == true
[ok] value: trueEnsure the repository holds no binaries
cnspec> azuredevops.repository.allFiles.none(isBinary == true)
[ok] value: trueEnsure deployments to production need an approval
cnspec> azuredevops.repository.project.environments.where(name == "production").all(protectionRules.any(type == "Approval"))
[ok] value: trueEnsure the person who ran a pipeline can't approve its deployment
cnspec> azuredevops.repository.project.environments.all(protectionRules.where(type == "Approval").all(preventSelfReview == true))
[ok] value: trueEnsure projects are private
Run this in a shell on the organization:
cnspec> azuredevops.organization.projects.all(visibility == "private")
[ok] value: trueTroubleshooting
- A
401error or a message about the personal access token: The token expired, was revoked, or belongs to another organization. Create a new one. - Every request fails for a service principal: The service principal isn't a user of the organization yet. Add it in Organization settings > Users.
- An error that the credential can't read the repositories of any project: The token lacks the Code (Read) scope, or the service principal isn't a member of the projects. Fix the scope or the project membership.
- A repository is missing from a scan: A
--reposor--repos-excludefilter might leave it out, its project might be inazuredevops.organization.unreadableProjects, or it might be empty or disabled. Queryazuredevops.organization.repositories { fullName status }to find out. - A repository has no Terraform or Kubernetes asset: The default
autotarget doesn't discover them. Pass--discover terraform,--discover k8s-manifests, or--discover all. - A
429error or slow scans: Azure DevOps throttles requests by cost for each user. cnspec backs off on its own. If it keeps happening, narrow the scan with--repos.
Learn more
- Azure DevOps Resource Pack Reference: every Azure DevOps resource and field cnspec can query
- Write Effective MQL: guide to authoring checks and queries