modules/Invoke-ADOPipelineCorrelator.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Correlates ADO secret findings to pipeline runs.
.DESCRIPTION
    Reads secret findings produced by Invoke-ADORepoSecrets, matches finding commit SHAs
    with pipeline builds (sourceVersion), and enriches with run-log metadata.
#>

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

    [string] $AdoProject,

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

    [string] $SecretsFindingsPath
)

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) } } }
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 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
        if ([string]::IsNullOrWhiteSpace($bodyText)) { return [PSCustomObject]@{} }
        return ($bodyText | ConvertFrom-Json -Depth 100)
    }
}

function Get-HttpStatusCodeFromException {
    param ([System.Exception]$Exception)

    if (-not $Exception) { return $null }
    if ($Exception.PSObject.Properties['Response'] -and $Exception.Response -and $Exception.Response.PSObject.Properties['StatusCode']) {
        try { return [int]$Exception.Response.StatusCode } catch { }
    }
    if ($Exception.InnerException) {
        return Get-HttpStatusCodeFromException -Exception $Exception.InnerException
    }
    return $null
}

function Test-IsTimeoutException {
    param ([System.Exception]$Exception)

    if (-not $Exception) { return $false }
    if ($Exception -is [System.TimeoutException]) { return $true }
    $message = [string]$Exception.Message
    if ($message -match '(?i)timed out|timeout|operation canceled|operation timed out') { return $true }
    if ($Exception.InnerException) { return (Test-IsTimeoutException -Exception $Exception.InnerException) }
    return $false
}

function Get-AzDevOpsToolVersion {
    [CmdletBinding()]
    param ()

    try {
        $azCmd = Get-Command -Name 'az' -ErrorAction SilentlyContinue
        if (-not $azCmd) { return '' }

        $json = & $azCmd.Source version --output json 2>$null
        if (-not $json) { return '' }
        $version = $json | ConvertFrom-Json -Depth 10
        if ($version.PSObject.Properties['extensions'] -and $version.extensions) {
            if ($version.extensions.PSObject.Properties['azure-devops']) {
                return "azure-devops/$($version.extensions.'azure-devops')"
            }
        }
    } catch {
        return ''
    }

    return ''
}

function Get-CommitEvidenceUrl {
    [CmdletBinding()]
    param (
        [string]$RepositoryCanonicalId,
        [string]$CommitSha
    )

    if ([string]::IsNullOrWhiteSpace($RepositoryCanonicalId) -or [string]::IsNullOrWhiteSpace($CommitSha)) {
        return ''
    }

    if ($RepositoryCanonicalId -match '^ado://([^/]+)/([^/]+)/repository/(.+)$') {
        $org = $Matches[1]
        $project = $Matches[2]
        $repository = $Matches[3]
        return "https://dev.azure.com/$org/$project/_git/$repository/commit/$CommitSha"
    }

    return ''
}

function New-CorrelationTitle {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)][string]$BaseTitle,
        [string]$BuildId,
        [string]$SecretFindingId
    )

    $buildKey = if ($BuildId) { $BuildId } else { 'none' }
    $secretKey = if ($SecretFindingId) { $SecretFindingId } else { 'none' }
    return "$BaseTitle [build:$buildKey secret:$secretKey]"
}

function New-CorrelatorInfoFinding {
    param (
        [Parameter(Mandatory)][string]$Title,
        [Parameter(Mandatory)][string]$Detail,
        [Parameter(Mandatory)][string]$ResourceId,
        [string]$Project = '',
        [string]$CorrelationStatus = 'correlated-fallback-project',
        [string]$ToolVersion = ''
    )

    $titleWithKeys = New-CorrelationTitle -BaseTitle $Title -BuildId '' -SecretFindingId ''
    [PSCustomObject]@{
        Id           = [guid]::NewGuid().ToString()
        Source       = 'ado-pipeline-correlator'
        Category     = 'Pipeline Run Correlation'
        Title        = (Remove-Credentials $titleWithKeys)
        Severity     = 'Info'
        Compliant    = $true
        Detail       = (Remove-Credentials $Detail)
        Remediation  = 'Ensure the PAT has Build (Read) and Project and Team (Read) scope for all target projects.'
        ResourceId   = (Remove-Credentials $ResourceId)
        LearnMoreUrl = 'https://learn.microsoft.com/en-us/azure/devops/pipelines/process/runs'
        SchemaVersion = '1.0'
        AdoOrg       = $AdoOrg
        AdoProject   = $Project
        CorrelationStatus = $CorrelationStatus
        ToolVersion = $ToolVersion
    }
}

function Get-AdoBuilds {
    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/builds?api-version=7.1&`$top=200"
    $body = Invoke-AdoApi -Uri $uri -Headers $Headers
    if ($body -and $body.PSObject.Properties['value']) { return @($body.value) }
    return @()
}

function Get-AdoBuildLogs {
    param (
        [Parameter(Mandatory)][string]$Org,
        [Parameter(Mandatory)][string]$Project,
        [Parameter(Mandatory)][string]$BuildId,
        [Parameter(Mandatory)][hashtable]$Headers
    )
    $orgEnc = [uri]::EscapeDataString($Org)
    $projectEnc = [uri]::EscapeDataString($Project)
    $uri = "https://dev.azure.com/$orgEnc/$projectEnc/_apis/build/builds/$BuildId/logs?api-version=7.1"
    $body = Invoke-AdoApi -Uri $uri -Headers $Headers
    if ($body -and $body.PSObject.Properties['value']) { return @($body.value) }
    return @()
}

if (-not $SecretsFindingsPath -or -not (Test-Path $SecretsFindingsPath)) {
    return [PSCustomObject]@{
        Source = 'ado-pipeline-correlator'
        Status = 'Skipped'
        Message = 'No secret findings file provided for pipeline correlation.'
        Findings = @()
        Errors   = @()
    }
}

$secrets = @()
try {
    $raw = Get-Content -Path $SecretsFindingsPath -Raw -ErrorAction Stop
    if (-not [string]::IsNullOrWhiteSpace($raw)) {
        $parsed = $raw | ConvertFrom-Json -Depth 100
        if ($parsed -is [System.Collections.IEnumerable] -and $parsed -isnot [string]) { $secrets = @($parsed) }
        elseif ($parsed) { $secrets = @($parsed) }
    }
} catch {
    return [PSCustomObject]@{
        Source = 'ado-pipeline-correlator'
        Status = 'Failed'
        Message = (Remove-Credentials "Failed to read secret findings: $($_.Exception.Message)")
        Findings = @()
        Errors   = @()
    }
}

if ($secrets.Count -eq 0) {
    return [PSCustomObject]@{
        Source = 'ado-pipeline-correlator'
        Status = 'Skipped'
        Message = 'No secret findings available for correlation.'
        Findings = @()
        Errors   = @()
    }
}

$pat = Resolve-AdoPat -Explicit $AdoPat
if (-not $pat) {
    return [PSCustomObject]@{
        Source = 'ado-pipeline-correlator'
        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" }

$byProject = @{}
foreach ($secret in $secrets) {
    $project = if ($AdoProject) { $AdoProject } elseif ($secret.PSObject.Properties['AdoProject'] -and $secret.AdoProject) { [string]$secret.AdoProject } else { '' }
    if ([string]::IsNullOrWhiteSpace($project)) { continue }
    if (-not $byProject.ContainsKey($project)) {
        $byProject[$project] = [System.Collections.Generic.List[object]]::new()
    }
    $byProject[$project].Add($secret)
}

$findings = [System.Collections.Generic.List[PSCustomObject]]::new()
$logLookupFailures = [System.Collections.Generic.List[string]]::new()
$projectFailures = [System.Collections.Generic.List[string]]::new()
$toolVersion = Get-AzDevOpsToolVersion

foreach ($entry in $byProject.GetEnumerator()) {
    $project = [string]$entry.Key
    $projectSecrets = @($entry.Value)

    try {
        $builds = @(Get-AdoBuilds -Org $AdoOrg -Project $project -Headers $headers)
        foreach ($secret in $projectSecrets) {
            $commitSha = if ($secret.PSObject.Properties['CommitSha'] -and $secret.CommitSha) { [string]$secret.CommitSha } else { '' }
            if ([string]::IsNullOrWhiteSpace($commitSha)) { continue }
            $secretSeverity = if ($secret.PSObject.Properties['Severity'] -and $secret.Severity) { [string]$secret.Severity } else { 'High' }
            $secretFile = if ($secret.PSObject.Properties['FilePath'] -and $secret.FilePath) { [string]$secret.FilePath } else { 'unknown-file' }
            $secretType = if ($secret.PSObject.Properties['SecretType'] -and $secret.SecretType) { [string]$secret.SecretType } else { 'unknown-secret' }
            $secretFindingId = if ($secret.PSObject.Properties['Id'] -and $secret.Id) { [string]$secret.Id } else { '' }
            $repositoryCanonicalId = if ($secret.PSObject.Properties['RepositoryCanonicalId'] -and $secret.RepositoryCanonicalId) { [string]$secret.RepositoryCanonicalId } else { '' }
            $commitUrl = if ($secret.PSObject.Properties['CommitUrl'] -and $secret.CommitUrl) { [string]$secret.CommitUrl } else { (Get-CommitEvidenceUrl -RepositoryCanonicalId $repositoryCanonicalId -CommitSha $commitSha) }

            $matchedBuilds = @($builds | Where-Object {
                    $_.PSObject.Properties['sourceVersion'] -and $_.sourceVersion -and
                    ([string]$_.sourceVersion).ToLowerInvariant().StartsWith($commitSha.ToLowerInvariant())
                })

            foreach ($build in $matchedBuilds) {
                $buildId = if ($build.PSObject.Properties['id']) { [string]$build.id } else { '' }
                $definitionName = if ($build.PSObject.Properties['definition'] -and $build.definition -and $build.definition.PSObject.Properties['name']) {
                    [string]$build.definition.name
                } else { 'unknown-pipeline' }
                $definitionId = if ($build.PSObject.Properties['definition'] -and $build.definition -and $build.definition.PSObject.Properties['id']) {
                    [string]$build.definition.id
                } else { $definitionName }

                $pipelineResourceId = "ado://$($AdoOrg.ToLowerInvariant())/$($project.ToLowerInvariant())/pipeline/$($definitionId.ToLowerInvariant())"
                $buildUrl = if ($build.PSObject.Properties['_links'] -and $build._links -and $build._links.PSObject.Properties['web'] -and $build._links.web.href) {
                    [string]$build._links.web.href
                } else {
                    "https://dev.azure.com/$AdoOrg/$project/_build/results?buildId=$buildId"
                }

                $logCount = 0
                try {
                    $logs = @(Get-AdoBuildLogs -Org $AdoOrg -Project $project -BuildId $buildId -Headers $headers)
                    $logCount = $logs.Count
                } catch {
                    $logLookupFailures.Add("$project/$buildId")
                    $findings.Add((New-CorrelatorInfoFinding -Title 'ADO pipeline logs inaccessible - skipped' `
                            -Detail "Build logs for '$project/$buildId' could not be read and were skipped. $($_.Exception.Message)" `
                            -ResourceId "https://dev.azure.com/$AdoOrg/$project/_build/results?buildId=$buildId" `
                            -Project $project -ToolVersion $toolVersion))
                }

                $title = New-CorrelationTitle -BaseTitle "Secret-bearing commit $($commitSha.Substring(0, [Math]::Min(8, $commitSha.Length))) executed in pipeline '$definitionName'" -BuildId $buildId -SecretFindingId $secretFindingId
                $detail = "Secret type '$secretType' in file '$secretFile' was detected in commit '$commitSha'. BuildId=$buildId; Logs=$logCount."

                $findings.Add([PSCustomObject]@{
                        Id                = [guid]::NewGuid().ToString()
                        Source            = 'ado-pipeline-correlator'
                        Category          = 'Pipeline Run Correlation'
                        Title             = (Remove-Credentials $title)
                        Severity          = $secretSeverity
                        Compliant         = $false
                        Detail            = (Remove-Credentials $detail)
                        Remediation       = 'Review the pipeline run, revoke exposed credentials, and rotate impacted secrets before re-running deployments.'
                        ResourceId        = $pipelineResourceId
                        LearnMoreUrl      = 'https://learn.microsoft.com/en-us/azure/devops/pipelines/process/runs'
                        SchemaVersion     = '1.0'
                        AdoOrg            = $AdoOrg
                        AdoProject        = $project
                        PipelineResourceId = $pipelineResourceId
                        BuildId           = $buildId
                        BuildUrl          = $buildUrl
                        BuildTimestamp    = if ($build.PSObject.Properties['startTime']) { [string]$build.startTime } else { '' }
                        CommitSha         = $commitSha
                        SecretFindingId   = $secretFindingId
                        SecretType        = $secretType
                        RepositoryCanonicalId = $repositoryCanonicalId
                        CommitUrl         = $commitUrl
                        CorrelationStatus = 'correlated-direct'
                        ToolVersion       = $toolVersion
                    })
            }

            if ($matchedBuilds.Count -eq 0) {
                $correlationStatus = if ($builds.Count -eq 0) { 'build-not-found' } else { 'uncorrelated' }
                $title = New-CorrelationTitle -BaseTitle "Secret-bearing commit $($commitSha.Substring(0, [Math]::Min(8, $commitSha.Length))) was not matched to a pipeline run" -BuildId '' -SecretFindingId $secretFindingId
                $detail = "Secret type '$secretType' in file '$secretFile' was detected in commit '$commitSha', but no matching build run was found in project '$project'."

                $findings.Add([PSCustomObject]@{
                        Id                  = [guid]::NewGuid().ToString()
                        Source              = 'ado-pipeline-correlator'
                        Category            = 'Pipeline Run Correlation'
                        Title               = (Remove-Credentials $title)
                        Severity            = $secretSeverity
                        Compliant           = $false
                        Detail              = (Remove-Credentials $detail)
                        Remediation         = 'Review pipeline triggers, verify commit-to-build lineage, rotate exposed secrets, and validate downstream artifacts.'
                        ResourceId          = "ado://$($AdoOrg.ToLowerInvariant())/$($project.ToLowerInvariant())/pipeline/unknown"
                        LearnMoreUrl        = 'https://learn.microsoft.com/en-us/azure/devops/pipelines/process/runs'
                        SchemaVersion       = '1.0'
                        AdoOrg              = $AdoOrg
                        AdoProject          = $project
                        PipelineResourceId  = "ado://$($AdoOrg.ToLowerInvariant())/$($project.ToLowerInvariant())/pipeline/unknown"
                        BuildId             = ''
                        BuildUrl            = "https://dev.azure.com/$AdoOrg/$project/_build"
                        BuildTimestamp      = ''
                        CommitSha           = $commitSha
                        SecretFindingId     = $secretFindingId
                        SecretType          = $secretType
                        RepositoryCanonicalId = $repositoryCanonicalId
                        CommitUrl           = $commitUrl
                        CorrelationStatus   = $correlationStatus
                        ToolVersion         = $toolVersion
                    })
            }
        }
    } catch {
        $projectFailures.Add($project)
        $statusCode = Get-HttpStatusCodeFromException -Exception $_.Exception
        $projectUri = "https://dev.azure.com/$AdoOrg/$project/_apis/build/builds"
        if ($statusCode -in @(401, 403)) {
            $findings.Add((New-CorrelatorInfoFinding -Title 'ADO project inaccessible for pipeline correlation - skipped' `
                    -Detail "Project '$project' returned HTTP $statusCode and was skipped for correlation. $($_.Exception.Message)" `
                    -ResourceId $projectUri -Project $project -ToolVersion $toolVersion))
        } elseif ($statusCode -eq 404) {
            $findings.Add((New-CorrelatorInfoFinding -Title 'ADO project not found for pipeline correlation - skipped' `
                    -Detail "Project '$project' returned HTTP 404 and was skipped for correlation. $($_.Exception.Message)" `
                    -ResourceId $projectUri -Project $project -ToolVersion $toolVersion))
        } elseif (Test-IsTimeoutException -Exception $_.Exception) {
            $findings.Add((New-CorrelatorInfoFinding -Title 'ADO project correlation timed out - skipped' `
                    -Detail "Project '$project' timed out during build lookup and was skipped. $($_.Exception.Message)" `
                    -ResourceId $projectUri -Project $project -ToolVersion $toolVersion))
        } else {
            $findings.Add((New-CorrelatorInfoFinding -Title 'ADO project correlation failed - skipped' `
                    -Detail "Project '$project' failed correlation and was skipped. $($_.Exception.Message)" `
                    -ResourceId $projectUri -Project $project -ToolVersion $toolVersion))
        }
        Write-Warning (Remove-Credentials "Failed to correlate builds for project '$project': $($_.Exception.Message)")
    }
}

$status = 'Success'

$message = "Correlated $($findings.Count) pipeline run finding(s) from $($secrets.Count) secret finding(s)."
if ($projectFailures.Count -gt 0) { $message += " Failed projects: $($projectFailures -join ', ')." }
if ($logLookupFailures.Count -gt 0) { $message += " Missing log lookups: $($logLookupFailures -join ', ')." }

return [PSCustomObject]@{
    Source = 'ado-pipeline-correlator'
    Status = $status
    Message = $message
    Findings = @($findings)
    Errors   = @()
}