modules/Invoke-ADOPipelineSecurity.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Azure DevOps pipeline security posture scanner.
.DESCRIPTION
    Queries Azure DevOps REST APIs to inspect build definitions, classic release
    definitions, variable groups, and environments. The first slice focuses on
    read-only posture signals such as missing approvals on production deploy
    surfaces, plaintext variable-group secrets, permissive CI triggers, and
    over-broad service-connection reuse. Variable values are never emitted.
.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 when not provided.
#>

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

    [string] $AdoProject,

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

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

$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) } } }
$script:ServiceConnectionInputNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($inputName in @(
        'azureSubscription',
        'azureServiceConnection',
        'azureResourceManagerConnection',
        'connectedServiceName',
        'connectedServiceNameARM',
        'serviceConnection',
        'serviceEndpoint'
    )) {
    $null = $script:ServiceConnectionInputNames.Add($inputName)
}

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
}

function Get-AdoToolVersion {
    $azCommand = Get-Command az -ErrorAction SilentlyContinue
    if (-not $azCommand) {
        return 'unknown'
    }

    try {
        $raw = & $azCommand.Source 'devops' '--version' 2>$null
        foreach ($line in @($raw)) {
            $text = [string]$line
            if ($text -match '(?i)azure-devops\s+([0-9][0-9a-zA-Z\.\-]*)') {
                return [string]$Matches[1]
            }
        }
    } catch {
        Write-Verbose "Could not determine az devops extension version: $($_.Exception.Message)"
    }

    return 'unknown'
}

function Format-AdoSegment {
    param ([string] $Value)

    if ([string]::IsNullOrWhiteSpace($Value)) {
        return 'unknown'
    }

    $normalized = $Value.Trim().ToLowerInvariant()
    $normalized = $normalized -replace '[\\/]+', '-'
    $normalized = $normalized -replace '\s+', '-'
    return $normalized
}

function Get-AdoPortalAssetUrl {
    param (
        [string] $Org,
        [string] $Project,
        [string] $AssetType,
        [string] $AssetId
    )

    if ([string]::IsNullOrWhiteSpace($Org) -or [string]::IsNullOrWhiteSpace($Project) -or [string]::IsNullOrWhiteSpace($AssetId)) {
        return ''
    }

    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $assetIdEnc = [uri]::EscapeDataString($AssetId)

    switch ($AssetType) {
        'BuildDefinition'   { return "https://dev.azure.com/$orgEnc/$projectEnc/_build/definition?definitionId=$assetIdEnc" }
        'ReleaseDefinition' { return "https://dev.azure.com/$orgEnc/$projectEnc/_release?definitionId=$assetIdEnc" }
        'Environment'       { return "https://dev.azure.com/$orgEnc/$projectEnc/_environments/$assetIdEnc" }
        'VariableGroup'     { return "https://dev.azure.com/$orgEnc/$projectEnc/_library?itemType=VariableGroups&view=VariableGroupView&variableGroupId=$assetIdEnc" }
        default             { return '' }
    }
}

function Get-AdoAssetApiUri {
    param (
        [string] $Org,
        [string] $Project,
        [string] $AssetType,
        [string] $AssetId
    )

    if ([string]::IsNullOrWhiteSpace($Org) -or [string]::IsNullOrWhiteSpace($Project)) {
        return ''
    }

    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $assetIdEnc = [uri]::EscapeDataString($AssetId)

    switch ($AssetType) {
        'BuildDefinition'   { return "https://dev.azure.com/$orgEnc/$projectEnc/_apis/build/definitions/$assetIdEnc`?api-version=7.1" }
        'ReleaseDefinition' { return "https://vsrm.dev.azure.com/$orgEnc/$projectEnc/_apis/release/definitions/$assetIdEnc`?api-version=7.1" }
        'Environment'       { return "https://dev.azure.com/$orgEnc/$projectEnc/_apis/distributedtask/environments/$assetIdEnc`?api-version=7.1-preview.1" }
        'VariableGroup'     { return "https://dev.azure.com/$orgEnc/$projectEnc/_apis/distributedtask/variablegroups/$assetIdEnc`?api-version=7.1-preview.2" }
        'ServiceConnection' { return "https://dev.azure.com/$orgEnc/$projectEnc/_apis/serviceendpoint/endpoints/$assetIdEnc`?api-version=7.1-preview.4" }
        default             { return '' }
    }
}

function Get-ControlTagFromRuleId {
    param ([string] $RuleId)

    if ([string]::IsNullOrWhiteSpace($RuleId)) { return '' }
    if ($RuleId -match '^(Approval-Missing)') { return 'Approval-Missing' }
    if ($RuleId -match '^(Approval-Present)') { return 'Approval-Present' }
    if ($RuleId -match '^(Approval-Verification)') { return 'Approval-Verification' }
    if ($RuleId -match '^(Branch-Unprotected)') { return 'Branch-Unprotected' }
    if ($RuleId -match '^(Secret-InVariable)') { return 'Secret-InVariable' }
    if ($RuleId -match '^(SecretStore-KeyVault-Missing)') { return 'SecretStore-KeyVault-Missing' }
    if ($RuleId -match '^(ServiceConnection-OverReuse)') { return 'ServiceConnection-OverReuse' }
    return $RuleId
}

function Get-ImpactForRuleId {
    param (
        [string] $RuleId,
        [string] $Severity
    )

    switch -Regex ($RuleId) {
        '^Approval-Missing-Production$' { return 'High' }
        '^Secret-InVariable$' { return 'High' }
        '^Branch-Unprotected$' { return 'Medium' }
        '^ServiceConnection-OverReuse$' { return 'Medium' }
        '^SecretStore-KeyVault-Missing$' { return 'Medium' }
        '^Approval-Verification-Error$' { return 'Medium' }
        default {
            switch -Regex ($Severity) {
                '^(?i)critical|high$' { return 'High' }
                '^(?i)medium$' { return 'Medium' }
                default { return 'Low' }
            }
        }
    }
}

function Get-EffortForRuleId {
    param ([string] $RuleId)

    switch -Regex ($RuleId) {
        '^Approval-Missing' { return 'Medium' }
        '^Secret-InVariable$' { return 'Medium' }
        '^ServiceConnection-OverReuse$' { return 'Medium' }
        default { return 'Low' }
    }
}

function New-RemediationSnippet {
    param (
        [string] $Org,
        [string] $Project,
        [string] $RuleId,
        [string] $AssetType,
        [string] $AssetId
    )

    if ([string]::IsNullOrWhiteSpace($RuleId)) {
        return @()
    }

    $commands = @(
        "az devops configure --defaults organization=https://dev.azure.com/$Org project=$Project"
    )

    switch ($RuleId) {
        'Branch-Unprotected' {
            $commands += "# Restrict CI trigger branches for build definition $AssetId in ADO UI or YAML."
        }
        'Approval-Missing-Production' {
            $commands += "# Add pre-deployment approvals/checks for production stages."
        }
        'Secret-InVariable' {
            $commands += "# Convert variable group secrets to secret variables or Key Vault linkage."
        }
        'SecretStore-KeyVault-Missing' {
            $commands += "# Link the variable group to Azure Key Vault."
        }
        'ServiceConnection-OverReuse' {
            $commands += "# Scope service connections per app/environment boundary."
        }
        default {
            $commands += "# Review and remediate $RuleId on $AssetType $AssetId."
        }
    }

    return @(
        @{
            language = 'bash'
            content  = ($commands -join [Environment]::NewLine)
        }
    )
}

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'
        $bodyText = [string]$webResponse.Content
        $body = if ([string]::IsNullOrWhiteSpace($bodyText)) {
            [PSCustomObject]@{}
        } else {
            $bodyText | ConvertFrom-Json -Depth 100
        }

        $continuationToken = $null
        if ($webResponse.Headers -and $webResponse.Headers.ContainsKey('x-ms-continuationtoken')) {
            $tokenValue = $webResponse.Headers['x-ms-continuationtoken']
            if ($tokenValue -is [array]) {
                $continuationToken = $tokenValue[0]
            } else {
                $continuationToken = $tokenValue
            }
        }

        [PSCustomObject]@{
            Body              = $body
            ContinuationToken = $continuationToken
        }
    }
}

function Get-AdoPagedValues {
    param (
        [Parameter(Mandatory)]
        [string] $Uri,

        [Parameter(Mandatory)]
        [hashtable] $Headers
    )

    $items = [System.Collections.Generic.List[object]]::new()
    $continuationToken = $null

    do {
        $pagedUri = $Uri
        if ($continuationToken) {
            $separator = if ($pagedUri -like '*?*') { '&' } else { '?' }
            $pagedUri += "$separator" + 'continuationToken=' + [uri]::EscapeDataString([string]$continuationToken)
        }

        $response = Invoke-AdoApi -Uri $pagedUri -Headers $Headers
        $body = if ($response) { $response.Body } else { $null }

        if ($body -and $body.PSObject.Properties['value']) {
            foreach ($item in @($body.value)) {
                $items.Add($item)
            }
        } elseif ($body) {
            $items.Add($body)
        }

        $continuationToken = if ($response) { $response.ContinuationToken } else { $null }
    } while ($continuationToken)

    return @($items)
}

function Get-AdoProjects {
    param (
        [Parameter(Mandatory)][string] $Org,
        [Parameter(Mandatory)][hashtable] $Headers
    )

    $orgEnc = [uri]::EscapeDataString($Org)
    $uri = "https://dev.azure.com/$orgEnc/_apis/projects?api-version=7.1&`$top=100"
    $projects = Get-AdoPagedValues -Uri $uri -Headers $Headers
    return @($projects | Where-Object { $_.name } | ForEach-Object { $_.name })
}

function Get-BuildDefinitions {
    param (
        [Parameter(Mandatory)][string] $Org,
        [Parameter(Mandatory)][string] $Project,
        [Parameter(Mandatory)][hashtable] $Headers
    )

    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $uri = "https://dev.azure.com/$orgEnc/$projectEnc/_apis/build/definitions?api-version=7.1&includeAllProperties=true&`$top=100"
    return @(Get-AdoPagedValues -Uri $uri -Headers $Headers)
}

function Get-ReleaseDefinitions {
    param (
        [Parameter(Mandatory)][string] $Org,
        [Parameter(Mandatory)][string] $Project,
        [Parameter(Mandatory)][hashtable] $Headers
    )

    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $uri = "https://vsrm.dev.azure.com/$orgEnc/$projectEnc/_apis/release/definitions?api-version=7.1&`$top=100"
    return @(Get-AdoPagedValues -Uri $uri -Headers $Headers)
}

function Get-VariableGroups {
    param (
        [Parameter(Mandatory)][string] $Org,
        [Parameter(Mandatory)][string] $Project,
        [Parameter(Mandatory)][hashtable] $Headers
    )

    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $uri = "https://dev.azure.com/$orgEnc/$projectEnc/_apis/distributedtask/variablegroups?api-version=7.1-preview.2&`$top=100"
    return @(Get-AdoPagedValues -Uri $uri -Headers $Headers)
}

function Get-Environments {
    param (
        [string] $Org,
        [string] $Project,
        [hashtable] $Headers
    )

    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $uri = "https://dev.azure.com/$orgEnc/$projectEnc/_apis/distributedtask/environments?api-version=7.1-preview.1&`$top=100"
    return @(Get-AdoPagedValues -Uri $uri -Headers $Headers)
}

function Get-EnvironmentChecks {
    param (
        [string] $Org,
        [string] $Project,
        [int] $EnvironmentId,
        [hashtable] $Headers
    )

    if ($EnvironmentId -le 0) {
        return [PSCustomObject]@{
            Success = $true
            Checks  = @()
            Error   = ''
        }
    }

    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $uri = "https://dev.azure.com/$orgEnc/$projectEnc/_apis/pipelines/checks/configurations?resourceType=environment&resourceId=$EnvironmentId&api-version=7.1-preview.1"

    try {
        return [PSCustomObject]@{
            Success = $true
            Checks  = @(Get-AdoPagedValues -Uri $uri -Headers $Headers)
            Error   = ''
        }
    } catch {
        $sanitized = Remove-Credentials "Could not read environment checks for '$Project/$EnvironmentId': $($_.Exception.Message)"
        Write-Verbose $sanitized
        return [PSCustomObject]@{
            Success = $false
            Checks  = @()
            Error   = $sanitized
        }
    }
}

function Get-CollectionCount {
    param ([object] $Value)

    if ($null -eq $Value) { return 0 }
    if ($Value -is [string]) {
        if ([string]::IsNullOrWhiteSpace($Value)) { return 0 }
        return 1
    }
    if ($Value -is [System.Collections.IDictionary]) { return $Value.Count }
    if ($Value -is [System.Collections.IEnumerable]) {
        $count = 0
        foreach ($item in $Value) {
            $count++
        }
        return $count
    }
    return 1
}

function Test-IsProductionName {
    param ([string] $Name)

    if ([string]::IsNullOrWhiteSpace($Name)) { return $false }
    return $Name -match '(?i)(^|[-_\s])(prod|production|live|prd)($|[-_\s])'
}

function Test-IsSensitiveVariableName {
    param ([string] $Name)

    if ([string]::IsNullOrWhiteSpace($Name)) { return $false }
    return $Name -match '(?i)(secret|password|token|key|credential|connectionstring|clientsecret|apikey|pat)'
}

function Test-IsGuidLike {
    param ([AllowNull()] [object] $Value)

    if ($null -eq $Value) { return $false }

    $candidate = if ($Value -is [string]) {
        $Value.Trim()
    } else {
        [string]$Value
    }

    if ([string]::IsNullOrWhiteSpace($candidate)) { return $false }

    $guidValue = [guid]::Empty
    return [guid]::TryParse($candidate, [ref]$guidValue)
}

function Test-IsServiceConnectionProperty {
    param (
        [string] $Name,
        [AllowNull()] [object] $Value
    )

    if ([string]::IsNullOrWhiteSpace($Name)) { return $false }
    if ($script:ServiceConnectionInputNames.Contains($Name)) { return $true }

    if ($Name -notmatch '(?i)(connectedservice(name(arm)?)?|serviceconnection(id)?|serviceendpoint(id)?|endpointid)$') {
        return $false
    }

    if (Test-IsGuidLike -Value $Value) {
        return $true
    }

    if ($null -ne $Value -and $Value.PSObject.Properties['id'] -and (Test-IsGuidLike -Value $Value.id)) {
        return $true
    }

    return $false
}

function Add-ServiceConnectionRefs {
    param (
        [object] $Node,
        [System.Collections.Generic.HashSet[string]] $Results
    )

    if ($null -eq $Node) { return }
    if ($Node -is [string]) { return }

    $properties = @()
    if ($Node -is [System.Collections.IDictionary]) {
        foreach ($key in $Node.Keys) {
            $properties += [PSCustomObject]@{ Name = [string]$key; Value = $Node[$key] }
        }
    } else {
        $properties = @($Node.PSObject.Properties)
    }

    foreach ($property in $properties) {
        $propName = [string]$property.Name
        $propValue = $property.Value

        if (Test-IsServiceConnectionProperty -Name $propName -Value $propValue) {
            if ($propValue -is [string]) {
                $candidate = $propValue.Trim()
                if ($candidate -and $candidate.Length -le 200) {
                    $null = $Results.Add($candidate)
                }
            } elseif ($null -ne $propValue) {
                if ($propValue.PSObject.Properties['name'] -and $propValue.name) {
                    $null = $Results.Add([string]$propValue.name)
                } elseif ($propValue.PSObject.Properties['id'] -and $propValue.id) {
                    $null = $Results.Add([string]$propValue.id)
                }
            }
        }

        if ($null -eq $propValue -or $propValue -is [string]) { continue }
        if ($propValue -is [System.Collections.IEnumerable] -and -not ($propValue -is [string])) {
            foreach ($item in $propValue) {
                Add-ServiceConnectionRefs -Node $item -Results $Results
            }
            continue
        }

        if ($propValue -is [System.Collections.IDictionary] -or @($propValue.PSObject.Properties).Count -gt 0) {
            Add-ServiceConnectionRefs -Node $propValue -Results $Results
        }
    }
}

function Get-ServiceConnectionReferences {
    param ([object] $Node)

    $results = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    Add-ServiceConnectionRefs -Node $Node -Results $results
    return @($results | Sort-Object)
}

function Get-VariableGroupReferences {
    param ([object] $Node)

    $results = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    if ($null -eq $Node) { return @() }

    $variableGroups = @()
    if ($Node.PSObject.Properties['variableGroups']) {
        $variableGroups = @($Node.variableGroups)
    }

    foreach ($group in $variableGroups) {
        if ($group -is [string] -or $group -is [int]) {
            $null = $results.Add([string]$group)
            continue
        }
        if ($group.PSObject.Properties['name'] -and $group.name) {
            $null = $results.Add([string]$group.name)
        } elseif ($group.PSObject.Properties['id'] -and $group.id) {
            $null = $results.Add([string]$group.id)
        }
    }

    return @($results | Sort-Object)
}

function Test-AnyBranchTrigger {
    param ([object] $Definition)

    if (-not $Definition.PSObject.Properties['triggers']) { return $false }

    foreach ($trigger in @($Definition.triggers)) {
        $triggerType = if ($trigger.PSObject.Properties['triggerType']) { [string]$trigger.triggerType } else { '' }
        if ([string]::IsNullOrWhiteSpace($triggerType)) {
            continue
        }

        if ($triggerType -notmatch '(?i)^(continuousintegration|batchedcontinuousintegration|gatedcheckin|pullrequest)$') {
            continue
        }

        $branchFilters = @()
        if ($trigger.PSObject.Properties['branchFilters']) {
            $branchFilters = @($trigger.branchFilters | Where-Object { $_ })
        }

        if ($branchFilters.Count -eq 0) {
            return $true
        }
    }

    return $false
}

function Get-StageApprovalCount {
    param ([object] $Stage)

    $count = 0

    foreach ($propName in @('approvals', 'checks')) {
        if ($Stage.PSObject.Properties[$propName]) {
            $count += Get-CollectionCount -Value $Stage.$propName
        }
    }

    foreach ($propName in @('preDeployApprovals', 'postDeployApprovals')) {
        if (-not $Stage.PSObject.Properties[$propName]) { continue }
        $approvalNode = $Stage.$propName
        if ($approvalNode -and $approvalNode.PSObject.Properties['approvals']) {
            $count += Get-CollectionCount -Value $approvalNode.approvals
        } else {
            $count += Get-CollectionCount -Value $approvalNode
        }
    }

    foreach ($propName in @('preDeploymentGates', 'postDeploymentGates')) {
        if ($Stage.PSObject.Properties[$propName]) {
            $count += Get-CollectionCount -Value $Stage.$propName
        }
    }

    return $count
}

function New-PipelineFinding {
    param (
        [string] $Org,
        [string] $Project,
        [string] $AssetType,
        [string] $AssetId,
        [string] $AssetName,
        [string] $Category,
        [string] $Title,
        [string] $RuleId,
        [bool] $Compliant,
        [string] $Severity,
        [string] $Detail,
        [string] $Remediation,
        [string] $LearnMoreUrl,
        [string] $ResourceId,
        [string[]] $EntityRefs = @(),
        [string] $ToolVersion = 'unknown'
    )

    $controlTag = Get-ControlTagFromRuleId -RuleId $RuleId
    $baselineTags = @(
        "Asset-$AssetType"
    )
    if (-not [string]::IsNullOrWhiteSpace($controlTag)) {
        $baselineTags += $controlTag
    }
    $deepLinkUrl = Get-AdoPortalAssetUrl -Org $Org -Project $Project -AssetType $AssetType -AssetId $AssetId
    $assetApiUri = Get-AdoAssetApiUri -Org $Org -Project $Project -AssetType $AssetType -AssetId $AssetId
    $evidenceUris = @()
    if ($assetApiUri) {
        $evidenceUris += $assetApiUri
    }
    $orgEnc = [uri]::EscapeDataString($Org)
    $evidenceUris += "https://auditservice.dev.azure.com/$orgEnc/_apis/audit/auditlog?api-version=7.1-preview.1"
    $impact = Get-ImpactForRuleId -RuleId $RuleId -Severity $Severity
    $effort = Get-EffortForRuleId -RuleId $RuleId
    $remediationSnippets = New-RemediationSnippet -Org $Org -Project $Project -RuleId $RuleId -AssetType $AssetType -AssetId $AssetId

    [PSCustomObject]@{
        Source        = 'ado-pipelines'
        ResourceId    = $ResourceId
        Category      = $Category
        RuleId        = $RuleId
        Title         = (Remove-Credentials $Title)
        Compliant     = [bool]$Compliant
        Severity      = $Severity
        Detail        = (Remove-Credentials $Detail)
        Remediation   = (Remove-Credentials $Remediation)
        LearnMoreUrl  = $LearnMoreUrl
        Pillar        = 'Security'
        Impact        = $impact
        Effort        = $effort
        DeepLinkUrl   = $deepLinkUrl
        RemediationSnippets = @($remediationSnippets)
        EvidenceUris  = @($evidenceUris | Select-Object -Unique)
        BaselineTags  = @($baselineTags | Select-Object -Unique)
        EntityRefs    = @($EntityRefs | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)
        ToolVersion   = $ToolVersion
        SchemaVersion = '1.0'
        AdoOrg        = $Org
        AdoProject    = $Project
        AssetType     = $AssetType
        AssetId       = $AssetId
        AssetName     = $AssetName
    }
}

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

$pair = ":$pat"
$bytes = [System.Text.Encoding]::UTF8.GetBytes($pair)
$base64 = [System.Convert]::ToBase64String($bytes)
$headers = @{ Authorization = "Basic $base64" }
$toolVersion = Get-AdoToolVersion

try {
    [string[]]$projects = @()
    if ($AdoProject) {
        $projects = @([string]$AdoProject)
    } else {
        $projects = @(Get-AdoProjects -Org $AdoOrg -Headers $headers)
    }

    if ($projects.Count -eq 0) {
        return [PSCustomObject]@{
            Source   = 'ado-pipelines'
            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()
    $partialProjects = [System.Collections.Generic.List[string]]::new()
    $serviceConnectionUsage = [System.Collections.Hashtable]::new([System.StringComparer]::OrdinalIgnoreCase)

    foreach ($project in $projects) {
        try {
            $buildDefinitions = @(Get-BuildDefinitions -Org $AdoOrg -Project $project -Headers $headers)
            $releaseDefinitions = @(Get-ReleaseDefinitions -Org $AdoOrg -Project $project -Headers $headers)
            $variableGroups = @(Get-VariableGroups -Org $AdoOrg -Project $project -Headers $headers)
            $environments = @(Get-Environments -Org $AdoOrg -Project $project -Headers $headers)

            foreach ($definition in $buildDefinitions) {
                $definitionId = if ($definition.PSObject.Properties['id'] -and $definition.id) { [string]$definition.id } else { '' }
                $definitionName = if ($definition.PSObject.Properties['name'] -and $definition.name) { [string]$definition.name } else { "build-$definitionId" }
                $serviceRefs = @(Get-ServiceConnectionReferences -Node $definition)
                $variableGroupRefs = @(Get-VariableGroupReferences -Node $definition)
                $definitionEntityRefs = [System.Collections.Generic.List[string]]::new()
                foreach ($serviceRef in $serviceRefs) {
                    $definitionEntityRefs.Add("$AdoOrg/$project/ServiceConnection/$serviceRef") | Out-Null
                }
                foreach ($groupRef in $variableGroupRefs) {
                    $definitionEntityRefs.Add("$AdoOrg/$project/VariableGroup/$groupRef") | Out-Null
                }

                foreach ($ref in $serviceRefs) {
                    if (-not $serviceConnectionUsage.ContainsKey($ref)) {
                        $serviceConnectionUsage[$ref] = [System.Collections.Generic.List[string]]::new()
                    }
                    $serviceConnectionUsage[$ref].Add("$project|pipeline|$definitionName")
                }

                $assetKey = if ($definitionId) { $definitionId } else { $definitionName }
                $resourceId = "ado://$(Format-AdoSegment $AdoOrg)/$(Format-AdoSegment $project)/pipeline/$(Format-AdoSegment $assetKey)"
                $defaultBranch = if ($definition.PSObject.Properties['repository'] -and $definition.repository -and $definition.repository.PSObject.Properties['defaultBranch']) {
                    [string]$definition.repository.defaultBranch
                } else {
                    ''
                }

                $inventoryTitle = "Pipeline definition: $definitionName"
                $inventoryDetail = "ServiceConnections=$($serviceRefs.Count); VariableGroups=$($variableGroupRefs.Count); DefaultBranch=$defaultBranch"
                $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'BuildDefinition' -AssetId $definitionId -AssetName $definitionName -Category 'Pipeline Definition' -RuleId 'Inventory-BuildDefinition' -Title $inventoryTitle -Compliant $true -Severity 'Info' -Detail $inventoryDetail -Remediation '' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/process/pipeline-triggers' -ResourceId $resourceId -EntityRefs @($definitionEntityRefs) -ToolVersion $toolVersion))

                if ((Test-IsProductionName -Name $definitionName) -and (Test-AnyBranchTrigger -Definition $definition)) {
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'BuildDefinition' -AssetId $definitionId -AssetName $definitionName -Category 'Pipeline Definition' -RuleId 'Branch-Unprotected' -Title "Pipeline '$definitionName' allows broad branch triggers" -Compliant $false -Severity 'Low' -Detail 'The definition appears to be production-facing but its CI trigger has no explicit branch filters.' -Remediation 'Restrict CI triggers to protected branches only (for example main/release/*) and require PR validation before deployment.' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/repos/azure-repos-git?view=azure-devops&tabs=yaml#ci-triggers' -ResourceId $resourceId -EntityRefs @($definitionEntityRefs) -ToolVersion $toolVersion))
                }
            }

            foreach ($releaseDefinition in $releaseDefinitions) {
                $releaseId = if ($releaseDefinition.PSObject.Properties['id'] -and $releaseDefinition.id) { [string]$releaseDefinition.id } else { '' }
                $releaseName = if ($releaseDefinition.PSObject.Properties['name'] -and $releaseDefinition.name) { [string]$releaseDefinition.name } else { "release-$releaseId" }
                $releaseKey = if ($releaseId) { $releaseId } else { $releaseName }
                $resourceId = "ado://$(Format-AdoSegment $AdoOrg)/$(Format-AdoSegment $project)/pipeline/release-$(Format-AdoSegment $releaseKey)"

                $missingStages = [System.Collections.Generic.List[string]]::new()
                $stages = if ($releaseDefinition.PSObject.Properties['environments']) { @($releaseDefinition.environments) } else { @() }
                $releaseEntityRefs = [System.Collections.Generic.List[string]]::new()
                foreach ($stage in $stages) {
                    if ($null -eq $stage) { continue }

                    $stageName = if ($stage.PSObject.Properties['name'] -and $stage.name) { [string]$stage.name } else { 'unnamed-stage' }
                    if (-not (Test-IsProductionName -Name $stageName)) { continue }
                    $releaseEntityRefs.Add("$AdoOrg/$project/Environment/$stageName") | Out-Null

                    $approvalCount = Get-StageApprovalCount -Stage $stage
                    if ($approvalCount -eq 0) {
                        $missingStages.Add($stageName)
                    }

                    foreach ($ref in @(Get-ServiceConnectionReferences -Node $stage)) {
                        if (-not $serviceConnectionUsage.ContainsKey($ref)) {
                            $serviceConnectionUsage[$ref] = [System.Collections.Generic.List[string]]::new()
                        }
                        $serviceConnectionUsage[$ref].Add("$project|release|$releaseName/$stageName")
                    }
                }

                if ($missingStages.Count -gt 0) {
                    $stageList = ($missingStages | Select-Object -Unique) -join ', '
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'ReleaseDefinition' -AssetId $releaseId -AssetName $releaseName -Category 'Release Definition' -RuleId 'Approval-Missing-Production' -Title "Release definition '$releaseName' has production stages without approvals" -Compliant $false -Severity 'High' -Detail "Production stages without approval evidence: $stageList." -Remediation 'Add pre-deployment approvals or equivalent environment checks before production release stages.' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/release/approvals/' -ResourceId $resourceId -EntityRefs @($releaseEntityRefs) -ToolVersion $toolVersion))
                } else {
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'ReleaseDefinition' -AssetId $releaseId -AssetName $releaseName -Category 'Release Definition' -RuleId 'Approval-Present' -Title "Release definition '$releaseName' has deployment approval coverage" -Compliant $true -Severity 'Info' -Detail 'No production stages were found without approval or gate metadata.' -Remediation '' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/release/approvals/' -ResourceId $resourceId -EntityRefs @($releaseEntityRefs) -ToolVersion $toolVersion))
                }
            }

            foreach ($group in $variableGroups) {
                $groupId = if ($group.PSObject.Properties['id'] -and $group.id) { [string]$group.id } else { '' }
                $groupName = if ($group.PSObject.Properties['name'] -and $group.name) { [string]$group.name } else { "group-$groupId" }
                $groupKey = if ($groupName) { $groupName } else { $groupId }
                $resourceId = "ado://$(Format-AdoSegment $AdoOrg)/$(Format-AdoSegment $project)/variablegroup/$(Format-AdoSegment $groupKey)"
                $isKeyVaultLinked = $false
                if ($group.PSObject.Properties['type'] -and [string]$group.type -eq 'AzureKeyVault') {
                    $isKeyVaultLinked = $true
                } elseif ($group.PSObject.Properties['providerData'] -and $group.providerData) {
                    $isKeyVaultLinked = $true
                }

                $plaintextSensitiveNames = [System.Collections.Generic.List[string]]::new()
                $plaintextCount = 0

                if ($group.PSObject.Properties['variables'] -and $group.variables) {
                    foreach ($property in @($group.variables.PSObject.Properties)) {
                        $variableName = [string]$property.Name
                        $variableMeta = $property.Value
                        $isSecret = $false
                        if ($null -ne $variableMeta -and $variableMeta.PSObject.Properties['isSecret']) {
                            $isSecret = [bool]$variableMeta.isSecret
                        }

                        if (-not $isSecret) {
                            $plaintextCount++
                            if (Test-IsSensitiveVariableName -Name $variableName) {
                                $plaintextSensitiveNames.Add($variableName)
                            }
                        }
                    }
                }

                if ($plaintextSensitiveNames.Count -gt 0) {
                    $namePreview = ($plaintextSensitiveNames | Select-Object -First 5) -join ', '
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'VariableGroup' -AssetId $groupId -AssetName $groupName -Category 'Variable Group' -RuleId 'Secret-InVariable' -Title "Variable group '$groupName' contains plaintext sensitive variables" -Compliant $false -Severity 'High' -Detail "Variable names marked as non-secret: $namePreview. Values were intentionally omitted." -Remediation 'Convert these variables to secret variables or link the group to Azure Key Vault.' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/library/variable-groups' -ResourceId $resourceId -ToolVersion $toolVersion))
                } elseif ((-not $isKeyVaultLinked) -and $plaintextCount -gt 0 -and (Test-IsProductionName -Name $groupName)) {
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'VariableGroup' -AssetId $groupId -AssetName $groupName -Category 'Variable Group' -RuleId 'SecretStore-KeyVault-Missing' -Title "Production variable group '$groupName' is not linked to Key Vault" -Compliant $false -Severity 'Medium' -Detail "The group contains $plaintextCount non-secret variable(s) and is stored as a standard library group." -Remediation 'Prefer Azure Key Vault-linked variable groups for production deployments and keep only non-sensitive metadata inline.' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/library/link-variable-groups-to-key-vaults' -ResourceId $resourceId -ToolVersion $toolVersion))
                } else {
                    $detail = if ($isKeyVaultLinked) {
                        'Key Vault linkage detected.'
                    } else {
                        "No plaintext sensitive variable names detected. Non-secret variable count=$plaintextCount."
                    }
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'VariableGroup' -AssetId $groupId -AssetName $groupName -Category 'Variable Group' -RuleId 'Secret-InVariable' -Title "Variable group '$groupName' passed the initial posture sweep" -Compliant $true -Severity 'Info' -Detail $detail -Remediation '' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/library/variable-groups' -ResourceId $resourceId -ToolVersion $toolVersion))
                }
            }

            foreach ($environment in $environments) {
                $environmentId = if ($environment.PSObject.Properties['id'] -and $environment.id) { [int]$environment.id } else { 0 }
                $environmentName = if ($environment.PSObject.Properties['name'] -and $environment.name) { [string]$environment.name } else { "environment-$environmentId" }
                $environmentKey = if ($environmentName) { $environmentName } else { $environmentId }
                $resourceId = "ado://$(Format-AdoSegment $AdoOrg)/$(Format-AdoSegment $project)/environment/$(Format-AdoSegment $environmentKey)"

                $checkResult = Get-EnvironmentChecks -Org $AdoOrg -Project $project -EnvironmentId $environmentId -Headers $headers
                $checks = @($checkResult.Checks)
                $checkCount = Get-CollectionCount -Value $checks

                if (-not $checkResult.Success) {
                    $partialProjects.Add($project)
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'Environment' -AssetId ([string]$environmentId) -AssetName $environmentName -Category 'Environment' -RuleId 'Approval-Verification-Error' -Title "Environment '$environmentName' check coverage could not be verified" -Compliant $false -Severity 'Info' -Detail 'Environment checks could not be retrieved for this scan, so the result is partial and should be re-run once the Azure DevOps checks API is reachable.' -Remediation 'Verify that the token can read environment checks, then re-run the scan to confirm approvals are configured.' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals?view=azure-devops' -ResourceId $resourceId -ToolVersion $toolVersion))
                } elseif ((Test-IsProductionName -Name $environmentName) -and $checkCount -eq 0) {
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'Environment' -AssetId ([string]$environmentId) -AssetName $environmentName -Category 'Environment' -RuleId 'Approval-Missing-Production' -Title "Environment '$environmentName' has no approval checks" -Compliant $false -Severity 'High' -Detail 'No approval, branch control, or other environment checks were returned for this production-like environment.' -Remediation 'Configure environment approvals or checks before allowing production deployments.' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals?view=azure-devops' -ResourceId $resourceId -ToolVersion $toolVersion))
                } else {
                    $findings.Add((New-PipelineFinding -Org $AdoOrg -Project $project -AssetType 'Environment' -AssetId ([string]$environmentId) -AssetName $environmentName -Category 'Environment' -RuleId 'Approval-Present' -Title "Environment '$environmentName' has check coverage" -Compliant $true -Severity 'Info' -Detail "Environment checks detected: $checkCount." -Remediation '' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/process/approvals?view=azure-devops' -ResourceId $resourceId -ToolVersion $toolVersion))
                }
            }
        } catch {
            Write-Warning (Remove-Credentials "Failed to scan project '$project': $_")
            $failedProjects.Add($project)
        }
    }

    foreach ($usageEntry in $serviceConnectionUsage.GetEnumerator()) {
        $uniqueConsumers = @($usageEntry.Value | Select-Object -Unique)
        if ($uniqueConsumers.Count -lt 3) { continue }

        $consumerPreview = ($uniqueConsumers | Select-Object -First 5) -join '; '
        $connectionName = [string]$usageEntry.Key
        $findings.Add((New-PipelineFinding -Org $AdoOrg -Project 'shared' -AssetType 'ServiceConnection' -AssetId $connectionName -AssetName $connectionName -Category 'Service Connection Usage' -RuleId 'ServiceConnection-OverReuse' -Title "Service connection '$connectionName' is reused across multiple pipeline assets" -Compliant $false -Severity 'Medium' -Detail "Referenced by $($uniqueConsumers.Count) assets: $consumerPreview" -Remediation 'Review whether this identity is over-scoped or should be split by environment or application boundary.' -LearnMoreUrl 'https://learn.microsoft.com/en-us/azure/devops/pipelines/library/service-endpoints' -ResourceId "ado://$(Format-AdoSegment $AdoOrg)/shared/serviceconnection/$(Format-AdoSegment $connectionName)" -EntityRefs @($uniqueConsumers) -ToolVersion $toolVersion))
    }

    $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'
    } elseif ($partialProjects.Count -gt 0) {
        'PartialSuccess'
    } else {
        'Success'
    }

    $message = "Scanned $($projects.Count) project(s), produced $($findings.Count) pipeline security finding(s)."
    if ($failedProjects.Count -gt 0) {
        $message += " Failed projects: $($failedProjects -join ', ')."
    }
    if ($partialProjects.Count -gt 0) {
        $message += " Partial environment-check coverage in: $((@($partialProjects | Select-Object -Unique)) -join ', ')."
    }

    return [PSCustomObject]@{
        Source   = 'ado-pipelines'
        Status   = $status
        Message  = (Remove-Credentials $message)
        Findings = @($findings)
        Errors   = @()
    }
} catch {
    $msg = Remove-Credentials $_.Exception.Message
    Write-Warning "ADO pipeline security scan failed: $msg"
    return [PSCustomObject]@{
        Source   = 'ado-pipelines'
        Status   = 'Failed'
        Message  = $msg
        Findings = @()
        Errors   = @()
    }
}