modules/normalizers/Normalize-ADOConnections.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Normalizer for ADO service connection findings.
.DESCRIPTION
    Converts raw ADO service connection wrapper output to v3 FindingRow objects.
    Platform=ADO, EntityType=ServiceConnection.
    CanonicalId = ado://{org}/{project}/serviceconnection/{connectionId} (lowercased).
#>

[CmdletBinding()]
param ()

. "$PSScriptRoot\..\shared\Schema.ps1"
. "$PSScriptRoot\..\shared\Canonicalize.ps1"

function Normalize-ADOConnections {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [PSCustomObject] $ToolResult
    )

    if ($ToolResult.Status -notin @('Success', 'PartialSuccess') -or -not $ToolResult.Findings) {
        return @()
    }

    $runId = [guid]::NewGuid().ToString()
    $normalized = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($finding in $ToolResult.Findings) {
        # Extract org/project/name for canonical ID
        $org = if ($finding.PSObject.Properties['AdoOrg'] -and $finding.AdoOrg) {
            $finding.AdoOrg
        } else { 'unknown' }

        $project = if ($finding.PSObject.Properties['AdoProject'] -and $finding.AdoProject) {
            $finding.AdoProject
        } else { 'unknown' }

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

        $authScheme = if ($finding.PSObject.Properties['AuthScheme'] -and $finding.AuthScheme) {
            $finding.AuthScheme
        } else { 'Unknown' }

        $authMechanism = if ($finding.PSObject.Properties['AuthMechanism'] -and $finding.AuthMechanism) {
            $finding.AuthMechanism
        } else { 'Unknown' }

        $isShared = if ($finding.PSObject.Properties['IsShared']) {
            [bool]$finding.IsShared
        } else { $false }

        # Build canonical ID keyed by org/project/connectionId for entity dedupe stability.
        $rawResourceId = if ($finding.PSObject.Properties['ResourceId'] -and $finding.ResourceId) {
            [string]$finding.ResourceId
        } else { '' }
        $connectionId = if ($finding.PSObject.Properties['ConnectionId'] -and $finding.ConnectionId) {
            [string]$finding.ConnectionId
        } else { '' }

        $canonicalId = ''
        if ($connectionId) {
            $canonicalId = "ado://$($org.ToLowerInvariant())/$($project.ToLowerInvariant())/serviceconnection/$($connectionId.ToLowerInvariant())"
        } elseif ($rawResourceId) {
            try {
                $canonicalId = ConvertTo-CanonicalAdoId -AdoId $rawResourceId
            } catch {
                $canonicalId = $rawResourceId.ToLowerInvariant()
            }
        }
        if (-not $canonicalId) {
            # Fallback: construct from parts
            $canonicalId = "ado://$($org.ToLowerInvariant())/$($project.ToLowerInvariant())/serviceconnection/unknown"
        }

        $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) {
            [string]$finding.Id
        } else {
            [guid]::NewGuid().ToString()
        }

        $title = if ($finding.PSObject.Properties['Title'] -and $finding.Title) {
            $finding.Title
        } else { 'Unknown service connection' }

        $category = 'Service Connection'
        $severity = 'Info'
        $compliant = $true

        $detail = if ($finding.PSObject.Properties['Detail'] -and $finding.Detail) {
            $finding.Detail
        } else {
            "Type=$connType; AuthScheme=$authScheme; AuthMechanism=$authMechanism; IsShared=$isShared"
        }

        $remediation = if ($finding.PSObject.Properties['Remediation'] -and $finding.Remediation) {
            $finding.Remediation
        } else { '' }

        $learnMore = if ($finding.PSObject.Properties['LearnMoreUrl'] -and $finding.LearnMoreUrl) {
            $finding.LearnMoreUrl
        } else { '' }

        $ruleId = if ($finding.PSObject.Properties['ConnectionType'] -and $finding.ConnectionType) {
            "ado.connection.$($finding.ConnectionType)"
        } else {
            'ado.connection'
        }
        $pillar = if ($finding.PSObject.Properties['Pillar'] -and $finding.Pillar) { [string]$finding.Pillar } else { 'Security' }
        $impact = if ($finding.PSObject.Properties['Impact'] -and $finding.Impact) { [string]$finding.Impact } else { '' }
        $effort = if ($finding.PSObject.Properties['Effort'] -and $finding.Effort) { [string]$finding.Effort } else { '' }
        $deepLinkUrl = if ($finding.PSObject.Properties['DeepLinkUrl'] -and $finding.DeepLinkUrl) { [string]$finding.DeepLinkUrl } else { '' }
        $remediationSnippets = @()
        if ($finding.PSObject.Properties['RemediationSnippets'] -and $finding.RemediationSnippets) {
            $snippetList = [System.Collections.Generic.List[hashtable]]::new()
            foreach ($snippet in @($finding.RemediationSnippets)) {
                if ($null -eq $snippet) { continue }
                $language = ''
                $content = ''
                if ($snippet -is [hashtable]) {
                    $language = [string]($snippet.language ?? $snippet.Language ?? '')
                    $content = [string]($snippet.content ?? $snippet.code ?? '')
                } else {
                    if ($snippet.PSObject.Properties['language']) { $language = [string]$snippet.language }
                    elseif ($snippet.PSObject.Properties['Language']) { $language = [string]$snippet.Language }
                    if ($snippet.PSObject.Properties['content']) { $content = [string]$snippet.content }
                    elseif ($snippet.PSObject.Properties['code']) { $content = [string]$snippet.code }
                }
                if ([string]::IsNullOrWhiteSpace($language)) { $language = 'text' }
                if ([string]::IsNullOrWhiteSpace($content)) { continue }
                $snippetList.Add(@{
                        language = $language
                        content  = $content
                    }) | Out-Null
            }
            $remediationSnippets = @($snippetList)
        }
        $evidenceUris = if ($finding.PSObject.Properties['EvidenceUris'] -and $finding.EvidenceUris) { @($finding.EvidenceUris) } else { @() }
        $baselineTags = if ($finding.PSObject.Properties['BaselineTags'] -and $finding.BaselineTags) { @($finding.BaselineTags) } else { @() }
        $entityRefs = if ($finding.PSObject.Properties['EntityRefs'] -and $finding.EntityRefs) { @($finding.EntityRefs) } else { @() }
        $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } else { '' }

        $row = New-FindingRow -Id $findingId `
            -Source 'ado-connections' -EntityId $canonicalId -EntityType 'ServiceConnection' `
            -Title $title -RuleId $ruleId -Compliant ([bool]$compliant) -ProvenanceRunId $runId `
            -Platform 'ADO' -Category $category -Severity $severity `
            -Detail $detail -Remediation $remediation `
            -LearnMoreUrl $learnMore -ResourceId ($rawResourceId) `
            -Pillar $pillar -Impact $impact -Effort $effort -DeepLinkUrl $deepLinkUrl `
            -RemediationSnippets $remediationSnippets -EvidenceUris $evidenceUris `
            -BaselineTags $baselineTags -EntityRefs $entityRefs -ToolVersion $toolVersion
        # Skip null rows (validation failed)
        if ($null -ne $row) {
            $normalized.Add($row)
        }
    }

    return @($normalized)
}