Service Connections in Azure DevOps provide a crucial link between your environment and external services such as Azure.
But as with most things, they tend to build up over time, which can lead to you having an over-exposed Azure tenant and potential pathways to abuse which you may not be aware of.
In this post, I’ll break down a PowerShell script designed to help you review your Azure Service Connections, ensuring they’re relevant, secure, and up-to-date. If you’d like to see the entire script you can skip down to “The Full Script” section.
Why Should You Review Azure DevOps Service Connections?
Service connections allow Azure DevOps pipelines to interact with various services like Azure, GitHub, or Docker Hub. While convenient, these connections often come with access privileges that could be exploited if misconfigured or left unmonitored.
Here’s why you should regularly review these connections –
- Reduce Attack Surface: Unused or outdated connections could still have active permissions, providing an unnecessary entry point for attackers.
- Compliance: Auditing service connections ensures that you stay compliant with internal security policies or external regulations.
- Credential Expiry: Some service connections use credentials or service principals that have an expiration date. Ensuring expired credentials are either removed or updated prevents outages or security risks.
- Accountability: Regular audits help track who set up a connection and why, giving better visibility into resource usage and permissions.
The Script Breakdown
The script shown below allows you to export all relevant information about Azure Service Connections to a CSV file, allowing for easy review in Excel or a similar program.
I’ll walk through the script to explain how it works and what each part achieves.
1. Initial Setup and Authentication
$orgName = '*enter your org name*'
$api = '7.1-preview.4'
$ErrorActionPreference = "Stop"
$personalAccessToken = Read-Host -Prompt "Enter the Personal Access Token for DevOps" # This should be a PAT Token that grants access to read Service Connections at minimum
- OrgName and API Version: Define the Azure DevOps organization you’re querying and the API version (
7.1-preview.4) for interacting with Azure DevOps. - Personal Access Token (PAT): You’re prompted to enter a PAT that allows PowerShell to authenticate and make requests to Azure DevOps. PATs should be kept secure and only granted necessary permissions.
2. Ensure Required Modules Are Installed
if (-not(Get-Module -ListAvailable -Name AzDevOps)) {
Install-Module AzDevOps
}
The script ensures that the AzDevOps module is installed, which is essential for interacting with Azure DevOps APIs.
3. Connecting to Azure DevOps & Microsoft Graph API
Connect-AdoOrganization -Orgname $orgName -PAT $personalAccessToken -ApiVersion $api
Connect-MgGraph -Scopes 'Application.Read.All'
These commands establish an authenticated session with DevOps and the Graph API. The Graph API connection allows the script to query details about registered applications related to service connections.
4. Fetching Projects and Service Connections
$AdoProjects = Get-AdoProject
$requestUri = "https://dev.azure.com/$orgName/$($AdoProjects[$x].name)/_apis/serviceendpoint/endpoints?api-version=$api&`$top=500"
$result = (Invoke-AdoEndpoint -Uri ([System.Uri]::new($requestUri)) -Method Get -Headers $Global:azDevOpsHeader).value
The script retrieves all DevOps projects. It then loops through each project to gather its associated service connections. It then makes an API call to get the service connections for each project. It focuses particularly on Azure RM service endpoints, which are used to connect to Azure resources.
5. Auditing Each Service Connection
For each Azure service connection, the script retrieves the usage history and checks the connection’s last usage –
$historyResult = Invoke-AdoEndpoint -Uri ([System.Uri]::new("https://dev.azure.com/$orgName/$($AdoProjects[$x].name)/_apis/serviceendpoint/$($record.id)/executionhistory?api-version=7.0")) -Method Get -Headers $Global:azDevOpsHeader
$lastUsed = if($historyResult.count -gt 0) {$historyResult[0].value[0].data.finishTime.toString().Substring(0, 10)} else {"Never"}
By checking the execution history, the script identifies whether the service connection has been used recently or if it might be obsolete.
6. Checking the Associated Enterprise Application
if($null -ne $record.data.appObjectId) {
$enterpriseApp = Get-MgApplication -ApplicationId $record.data.appObjectId -ErrorAction Stop
$registeredApp = Get-MgServicePrincipal -ServicePrincipalId $record.data.spnObjectId -ErrorAction Stop | Select-Object *
}
For each service connection, the script checks the enterprise application (if one exists). It retrieves the related service principal and app details, which are essential for auditing access rights and checking credential expiry.
7. Exporting the Audit Results
Finally, the script compiles all the data (project name, connection name, last usage, credential expiry, etc.) and exports it to a CSV file for further review –
$output | export-csv -NoClobber -NoTypeInformation "C:\temp\devopsServiceConnections-$($date.Year)$($date.Month)$($date.Day)$($date.Millisecond).csv"
Invoke-Item "C:\temp\devopsServiceConnections-$($date.Year)$($date.Month)$($date.Day)$($date.Millisecond).csv"
This allows you to open the file in Excel or any other tool to manually inspect the results.
The Full Script
$orgName = '#########' # Name of the DevOps organisation
$api = '7.1-preview.4' # The version of the API to utilize
$ErrorActionPreference = "Stop"
$personalAccessToken = Read-Host -Prompt "Enter the Personal Access Token for DevOps" #Create a Personal access Token in DevOps
# Install the AzDevOps module if required
if (-not(Get-Module -ListAvailable -Name AzDevOps)) {
Install-Module AzDevOps
}
# Connect to Azure DevOps & Azure
Connect-AdoOrganization -Orgname $orgName -PAT $personalAccessToken -ApiVersion $api
Connect-MgGraph -Scopes 'Application.Read.All'
# Get all available projects from ADO
$AdoProjects = Get-AdoProject
$output = @()
for($x=0; $x -lt $AdoProjects.length; $x++) {
# Retrieve the Service Endpoints (Service connections) of a project
$requestUri = "https://dev.azure.com/$orgName/$($AdoProjects[$x].name)/_apis/serviceendpoint/endpoints?api-version=$api&`$top=500"
$result = (Invoke-AdoEndpoint -Uri ([System.Uri]::new($requestUri)) -Method Get -Headers $Global:azDevOpsHeader).value
Write-Host "Retrieved $($result.count) results total for the $($AdoProjects[$x].name) project"
if($result.count -eq 0) {
continue
}
# Filter down to the Azure RM Service Endpoints
$azureEndpoints = $result | Where-Object {$_.type -eq 'azurerm'}
# Loop through each Service Connection, get details and output result to the $output array
if($azureEndpoints.length -eq 0) {
continue
}
for($i=0; $i -lt $azureEndPoints.count; $i++) {
$record = $azureEndpoints[$i]
# Get the usage history of each Service Endpoint, hardcoded api-version 7.0 otherwise it returns an error
$historyResult = Invoke-AdoEndpoint -Uri ([System.Uri]::new("https://dev.azure.com/$orgName/$($AdoProjects[$x].name)/_apis/serviceendpoint/$($record.id)/executionhistory?api-version=7.0")) -Method Get -Headers $Global:azDevOpsHeader
Write-Host "Retrieved $($historyResult.count) Usage records for $($record.name)"
if($historyResult.count -gt 0 -and $null -ne $historyResult[0].value.data[0].finishTime) {
$lastUsed = $historyResult[0].value[0].data.finishTime.toString().Substring(0, 10)
} else {
$lastUsed = "Never"
}
# Get the Enterprise App name
try {
if($null -ne $record.data.appObjectId) {
$enterpriseApp = Get-MgApplication -ApplicationId $record.data.appObjectId -ErrorAction Stop
$registeredApp = Get-MgServicePrincipal -ServicePrincipalId $record.data.spnObjectId -ErrorAction Stop | Select-Object *
} else {
$enterpriseApp = "appObjectId is blank for $($record.name)"
$registeredApp = "appObjectId is blank for $($record.name)"
}
} catch [System.Exception] {
$enterpriseApp = "Enterprise Application not found"
$registeredApp = "Registered Application not found"
} catch {
$message = $_.Exception.Message + " - " + $_.Exception.GetType().FullName
Write-Error $message
Write-Host "at $i"
}
$credentialExpiry = if($null -ne $enterpriseApp.PasswordCredentials) {($enterpriseApp.PasswordCredentials | sort-object EndDateTime -Descending | Select-Object EndDateTime -First 1).EndDateTime} else {(Get-Date).AddDays(1)}
$output += [pscustomobject]@{
ADOProject = $AdoProjects[$x].name
connectionName = $record.name
lastUsed = $lastused
clientExpiry = $credentialExpiry
clientExpired = if($credentialExpiry -lt (Get-Date) -or $credentialExpiry.GetType().Name -ne 'DateTime') {$true} else {$false}
createdBy = $record.createdBy.displayName
createdByEmail = $record.createdBy.uniqueName
description = $record.description
authorization = $record.authorization.scheme
isShared = $record.isShared
isReady = $record.isReady
subscriptionName = $record.data.subscriptionName
enterpriseAppName = if($null -ne $enterpriseApp.DisplayName) {$enterpriseApp.DisplayName} else {$enterpriseApp}
connectionId = $record.id
subscriptionId = $record.data.subscriptionId
enterpriseAppId = $record.data.appObjectId
registeredAppId = if($null -ne $registeredApp.AppId) {$registeredApp.AppId} else {$registeredApp}
spnObjectId = $record.data.spnObjectId
}
}
}
$date = Get-Date
$output | export-csv -NoClobber -NoTypeInformation "C:\temp\devopsServiceConnections-$($date.Year)$($date.Month)$($date.Day)$($date.Millisecond).csv"
Invoke-Item "C:\temp\devopsServiceConnections-$($date.Year)$($date.Month)$($date.Day)$($date.Millisecond).csv"
Disconnect-MgGraph
Make Regular Audits Part of Your Routine
Security in cloud environments like Azure is always evolving, and proactive audits are essential. This PowerShell script provides an automated way to review and clean up Azure DevOps service connections. Removing unused or expired connections limits potential attack vectors, ensures compliance, and enhances the overall security of your Azure DevOps infrastructure.
Regularly running scripts like this one can significantly bolster your organization’s security posture, minimizing the risk of forgotten connections creating vulnerabilities.
I hope this has been helpful and if you have any thoughts or feedback on the script or the article please do comment!








Leave a comment