modules/Invoke-ADOServiceConnections.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    ADO service connection inventory scanner.
.DESCRIPTION
    Queries Azure DevOps REST API to inventory service connections across one or
    all projects in an organization. Returns the v1 wrapper contract with Source,
    Status, Message, and Findings. Each finding is an informational inventory
    record (Compliant=$true, Severity=Info) capturing connection type, auth
    scheme, and sharing status.
.PARAMETER AdoOrg
    Azure DevOps organization name (required).
.PARAMETER AdoProject
    Project name. When omitted, all projects in the organization are scanned.
.PARAMETER AdoPat
    Personal access token. Falls back to ADO_PAT_TOKEN, AZURE_DEVOPS_EXT_PAT,
    or AZ_DEVOPS_PAT environment variables when not provided.
#>

[CmdletBinding()]
param (
    [Parameter(Mandatory)]
    [Alias('AdoOrganization')]
    [ValidateNotNullOrEmpty()]
    [string] $AdoOrg,

    [string] $AdoProject,

    [Alias('AdoPatToken')]
    [string] $AdoPat
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Dot-source shared helpers
$sharedDir = Join-Path $PSScriptRoot 'shared'
. (Join-Path $sharedDir 'Retry.ps1')
. (Join-Path $sharedDir 'Sanitize.ps1')


. (Join-Path $sharedDir 'New-WrapperEnvelope.ps1')
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
# ---------------------------------------------------------------------------
# Resolve PAT
# ---------------------------------------------------------------------------
function Resolve-AdoPat {
    param ([string]$Explicit)
    if ($Explicit) { return $Explicit }
    if ($env:ADO_PAT_TOKEN) { return $env:ADO_PAT_TOKEN }
    if ($env:AZURE_DEVOPS_EXT_PAT) { return $env:AZURE_DEVOPS_EXT_PAT }
    if ($env:AZ_DEVOPS_PAT) { return $env:AZ_DEVOPS_PAT }
    return $null
}

$pat = Resolve-AdoPat -Explicit $AdoPat
if (-not $pat) {
    return [PSCustomObject]@{
        Source   = 'ado-connections'
        Status   = 'Skipped'
        Message  = 'No ADO PAT provided. Set -AdoPat/-AdoPatToken, ADO_PAT_TOKEN, AZURE_DEVOPS_EXT_PAT, or AZ_DEVOPS_PAT.'
        Findings = @()
        Errors   = @()
    }
}

# Build auth header: Basic base64(:$pat)
$pair = ":$pat"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($pair)
$base64 = [System.Convert]::ToBase64String($bytes)
$headers = @{ Authorization = "Basic $base64" }

# ---------------------------------------------------------------------------
# API helpers
# ---------------------------------------------------------------------------
function Invoke-AdoApi {
    param (
        [Parameter(Mandatory)]
        [string] $Uri,
        [Parameter(Mandatory)]
        [hashtable] $Headers
    )
    Invoke-WithRetry -ScriptBlock {
        $webResponse = Invoke-WebRequest -Uri $Uri -Headers $Headers -Method Get -ContentType 'application/json'
        $body = $webResponse.Content | ConvertFrom-Json
        $ct = $null
        if ($webResponse.Headers.ContainsKey('x-ms-continuationtoken')) {
            $headerValue = $webResponse.Headers['x-ms-continuationtoken']
            # Headers may return as string[] or string
            if ($headerValue -is [array]) {
                $ct = $headerValue[0]
            } else {
                $ct = $headerValue
            }
        }
        [PSCustomObject]@{
            Body              = $body
            ContinuationToken = $ct
        }
    }
}

# ---------------------------------------------------------------------------
# List projects (when no specific project given)
# ---------------------------------------------------------------------------
function Get-AdoProjects {
    param (
        [string] $Org,
        [hashtable] $Headers
    )
    $projects = [System.Collections.Generic.List[string]]::new()
    $continuationToken = $null
    $orgEnc = [uri]::EscapeDataString($Org)
    do {
        $uri = "https://dev.azure.com/$orgEnc/_apis/projects?api-version=7.1&`$top=100"
        if ($continuationToken) {
            $uri += "&continuationToken=$continuationToken"
        }
        $response = Invoke-AdoApi -Uri $uri -Headers $Headers
        $body = if ($response) { $response.Body } else { $null }
        if ($body -and $body.PSObject.Properties['value']) {
            foreach ($p in @($body.value)) {
                $projects.Add($p.name)
            }
        }
        $continuationToken = if ($response) { $response.ContinuationToken } else { $null }
    } while ($continuationToken)
    return @($projects)
}

# ---------------------------------------------------------------------------
# List service connections for a project
# ---------------------------------------------------------------------------
function Get-AdoServiceConnections {
    param (
        [string] $Org,
        [string] $Project,
        [hashtable] $Headers
    )
    $connections = [System.Collections.Generic.List[PSCustomObject]]::new()
    $continuationToken = $null
    $orgEnc     = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    do {
        $uri = "https://dev.azure.com/$orgEnc/$projectEnc/_apis/serviceendpoint/endpoints?api-version=7.1&`$top=100"
        if ($continuationToken) {
            $uri += "&continuationToken=$continuationToken"
        }
        $response = Invoke-AdoApi -Uri $uri -Headers $Headers
        $body = if ($response) { $response.Body } else { $null }
        if ($body -and $body.PSObject.Properties['value']) {
            foreach ($c in @($body.value)) {
                $connections.Add($c)
            }
        }
        $continuationToken = if ($response) { $response.ContinuationToken } else { $null }
    } while ($continuationToken)
    return @($connections)
}

# ---------------------------------------------------------------------------
# Build a finding from a service connection
# ---------------------------------------------------------------------------
function ConvertTo-ConnectionFinding {
    param (
        [string] $Org,
        [string] $Project,
        [PSCustomObject] $Connection
    )
    $connName = if ($Connection.PSObject.Properties['name'] -and $Connection.name) {
        $Connection.name
    } else { 'unknown' }

    $connType = if ($Connection.PSObject.Properties['type'] -and $Connection.type) {
        $Connection.type
    } else { 'Unknown' }

    # Extract authorization scheme
    $authScheme = 'Unknown'
    if ($Connection.PSObject.Properties['authorization'] -and $Connection.authorization) {
        $auth = $Connection.authorization
        if ($auth.PSObject.Properties['scheme'] -and $auth.scheme) {
            $authScheme = $auth.scheme
        }
    }

    $normalizedAuthScheme = switch ($authScheme) {
        'Token'                  { 'PAT' }
        'ManagedServiceIdentity' { 'ManagedIdentity' }
        default                  { $authScheme }
    }

    function Get-ConnectionAuthMechanism {
        param (
            [string] $Scheme,
            [PSCustomObject] $ConnectionObject
        )

        switch ($Scheme) {
            'Token' { return 'PAT' }
            'WorkloadIdentityFederation' { return 'WorkloadIdentityFederation' }
            'ManagedServiceIdentity' { return 'ManagedIdentity' }
            'ManagedIdentity' { return 'ManagedIdentity' }
            'ServicePrincipal' {
                $parameters = $null
                if ($ConnectionObject -and
                    $ConnectionObject.PSObject.Properties['authorization'] -and
                    $ConnectionObject.authorization -and
                    $ConnectionObject.authorization.PSObject.Properties['parameters']) {
                    $parameters = $ConnectionObject.authorization.parameters
                }

                if ($parameters) {
                    $parameterMap = @{}
                    foreach ($p in $parameters.PSObject.Properties) {
                        $parameterMap[$p.Name.ToLowerInvariant()] = [string]$p.Value
                    }

                    if ($parameterMap.ContainsKey('authenticationtype') -and
                        $parameterMap['authenticationtype'].ToLowerInvariant().Contains('certificate')) {
                        return 'Certificate'
                    }
                    if ($parameterMap.ContainsKey('serviceprincipalcertificate') -or
                        $parameterMap.ContainsKey('clientcertificate')) {
                        return 'Certificate'
                    }
                    if ($parameterMap.ContainsKey('serviceprincipalkey') -or
                        $parameterMap.ContainsKey('clientsecret')) {
                        return 'ClientSecret'
                    }
                }

                return 'ClientSecret'
            }
            default { return $Scheme }
        }
    }

    $authMechanism = Get-ConnectionAuthMechanism -Scheme $authScheme -ConnectionObject $Connection

    # isShared flag
    $isShared = $false
    if ($Connection.PSObject.Properties['isShared']) {
        $isShared = [bool]$Connection.isShared
    }

    $connId = if ($Connection.PSObject.Properties['id'] -and $Connection.id) {
        $Connection.id
    } else { '' }

    $resourceId = "ado://$($Org.ToLowerInvariant())/$($Project.ToLowerInvariant())/serviceconnection/$($connName.ToLowerInvariant())"
    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $connIdEnc = [uri]::EscapeDataString($connId)
    $deepLinkUrl = "https://dev.azure.com/$orgEnc/$projectEnc/_settings/adminservices?resourceId=$connIdEnc"
    $connectionApiUri = "https://dev.azure.com/$orgEnc/$projectEnc/_apis/serviceendpoint/endpoints/${connIdEnc}?api-version=7.1"
    $auditLogUri = "https://dev.azure.com/$orgEnc/_settings/audit"

    $impact = 'Medium'
    $authSchemeForImpact = $normalizedAuthScheme.ToLowerInvariant()
    $authMechanismForImpact = $authMechanism.ToLowerInvariant()
    if ($isShared -and (($authSchemeForImpact -eq 'pat') -or ($authMechanismForImpact -eq 'clientsecret'))) {
        $impact = 'High'
    } elseif ((-not $isShared -and $authMechanismForImpact -eq 'clientsecret') -or
        ($isShared -and $authSchemeForImpact -eq 'managedidentity')) {
        $impact = 'Medium'
    } elseif ((@('workloadidentityfederation', 'managedidentity') -contains $authMechanismForImpact) -and (-not $isShared)) {
        $impact = 'Low'
    }

    $effort = if ($isShared) { 'Medium' } else { 'Low' }
    $sharingTag = if ($isShared) { 'Connection-Shared' } else { 'Connection-Scoped' }
    $baselineTags = @(
        "AuthScheme-$normalizedAuthScheme",
        "Auth-$authMechanism",
        $sharingTag
    )
    $entityRefs = @(
        "ado-org:$Org",
        "ado-project:$Org/$Project",
        "ado-service-connection:$Org/$Project/$connId"
    )
    $remediationText = if ($isShared) {
        'Migrate the shared service connection to workload identity federation and coordinate with dependent teams.'
    } else {
        'Migrate this service connection to workload identity federation and remove long-lived credentials.'
    }
    $remediationSnippets = @(
        @{
            language = 'bash'
            content  = "az devops service-endpoint update --id `"$connId`" --organization `"https://dev.azure.com/$Org`" --project `"$Project`""
        }
    )

    [PSCustomObject]@{
        Source        = 'ado-connections'
        ResourceId    = $resourceId
        Category      = 'Service Connection'
        Title         = "$connType connection: $connName"
        Compliant     = $true
        Severity      = 'Info'
        Detail        = "Type=$connType; AuthScheme=$normalizedAuthScheme; AuthMechanism=$authMechanism; IsShared=$isShared"
        Remediation   = $remediationText
        LearnMoreUrl  = 'https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints'
        SchemaVersion = '1.0'
        Pillar        = 'Security'
        Impact        = $impact
        Effort        = $effort
        DeepLinkUrl   = $deepLinkUrl
        RemediationSnippets = $remediationSnippets
        EvidenceUris  = @($connectionApiUri, $auditLogUri)
        BaselineTags  = $baselineTags
        EntityRefs    = $entityRefs
        ToolVersion   = 'ado-rest-api-7.1'
        AdoOrg        = $Org
        AdoProject    = $Project
        ConnectionId  = $connId
        ConnectionType = $connType
        AuthScheme    = $normalizedAuthScheme
        AuthMechanism = $authMechanism
        IsShared      = $isShared
    }
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
try {
    $projects = @()
    if ($AdoProject) {
        $projects = @($AdoProject)
    } else {
        $projects = @(Get-AdoProjects -Org $AdoOrg -Headers $headers)
        if ($projects.Count -eq 0) {
            return [PSCustomObject]@{
                Source   = 'ado-connections'
                Status   = 'Success'
                Message  = "No projects found in organization '$AdoOrg'."
                Findings = @()
                Errors   = @()
            }
        }
    }

    $findings = [System.Collections.Generic.List[PSCustomObject]]::new()
    $failedProjects = [System.Collections.Generic.List[string]]::new()

    foreach ($proj in $projects) {
        try {
            $connections = Get-AdoServiceConnections -Org $AdoOrg -Project $proj -Headers $headers
            foreach ($conn in $connections) {
                $finding = ConvertTo-ConnectionFinding -Org $AdoOrg -Project $proj -Connection $conn
                $findings.Add($finding)
            }
        } catch {
            Write-Warning (Remove-Credentials "Failed to scan project '$proj': $_")
            $failedProjects.Add($proj)
        }
    }

    $status = if ($failedProjects.Count -gt 0 -and $failedProjects.Count -lt $projects.Count) {
        'PartialSuccess'
    } elseif ($failedProjects.Count -ge $projects.Count -and $projects.Count -gt 0) {
        'Failed'
    } else {
        'Success'
    }
    $message = "Scanned $($projects.Count) project(s), found $($findings.Count) service connection(s)."
    if ($failedProjects.Count -gt 0) {
        $message += " Failed projects: $($failedProjects -join ', ')."
    }

    return [PSCustomObject]@{
        Source   = 'ado-connections'
        Status   = $status
        Message  = $message
        Findings = @($findings)
        Errors   = @()
    }
} catch {
    $errMsg = Remove-Credentials "$_"
    Write-Warning "ADO service connection scan failed: $errMsg"
    return [PSCustomObject]@{
        Source   = 'ado-connections'
        Status   = 'Failed'
        Message  = $errMsg
        Findings = @()
        Errors   = @()
    }
}