modules/normalizers/Normalize-ADOPipelineCorrelator.ps1
|
#Requires -Version 7.4 [CmdletBinding()] param () . "$PSScriptRoot\..\shared\Schema.ps1" . "$PSScriptRoot\..\shared\Canonicalize.ps1" function Normalize-ADOPipelineCorrelator { [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) { $org = if ($finding.PSObject.Properties['AdoOrg'] -and $finding.AdoOrg) { [string]$finding.AdoOrg } else { 'unknown' } $project = if ($finding.PSObject.Properties['AdoProject'] -and $finding.AdoProject) { [string]$finding.AdoProject } else { 'unknown' } $buildId = if ($finding.PSObject.Properties['BuildId'] -and $finding.BuildId) { [string]$finding.BuildId } else { '' } $secretFindingId = if ($finding.PSObject.Properties['SecretFindingId'] -and $finding.SecretFindingId) { [string]$finding.SecretFindingId } else { '' } $commitSha = if ($finding.PSObject.Properties['CommitSha'] -and $finding.CommitSha) { [string]$finding.CommitSha } else { '' } $repositoryCanonicalId = if ($finding.PSObject.Properties['RepositoryCanonicalId'] -and $finding.RepositoryCanonicalId) { [string]$finding.RepositoryCanonicalId } else { '' } $commitUrl = if ($finding.PSObject.Properties['CommitUrl'] -and $finding.CommitUrl) { [string]$finding.CommitUrl } else { '' } $secretCategory = if ($finding.PSObject.Properties['SecretType'] -and $finding.SecretType) { [string]$finding.SecretType } else { '' } $correlationStatus = if ($finding.PSObject.Properties['CorrelationStatus'] -and $finding.CorrelationStatus) { [string]$finding.CorrelationStatus } else { 'uncorrelated' } $pipelineIdRaw = if ($finding.PSObject.Properties['PipelineResourceId'] -and $finding.PipelineResourceId) { [string]$finding.PipelineResourceId } else { "ado://$($org.ToLowerInvariant())/$($project.ToLowerInvariant())/pipeline/unknown" } $entityId = '' try { $entityId = (ConvertTo-CanonicalEntityId -RawId $pipelineIdRaw -EntityType 'Pipeline').CanonicalId } catch { $entityId = $pipelineIdRaw.ToLowerInvariant() } $severity = switch -Regex ([string]$finding.Severity) { '^(?i)critical$' { 'Critical' } '^(?i)high$' { 'High' } '^(?i)medium$' { 'Medium' } '^(?i)low$' { 'Low' } '^(?i)info$' { 'Info' } default { 'Info' } } $impact = switch -Regex ($correlationStatus) { '^(?i)correlated-direct$' { 'High' } '^(?i)correlated-fallback-project$' { 'Medium' } default { 'Low' } } $buildResultUrl = if ($buildId) { "https://dev.azure.com/$org/$project/_build/results?buildId=$buildId&view=results" } else { '' } $buildLogsUrl = if ($buildId) { "https://dev.azure.com/$org/$project/_build/results?buildId=$buildId&view=logs" } else { '' } $buildUrl = if ($finding.PSObject.Properties['BuildUrl'] -and $finding.BuildUrl) { [string]$finding.BuildUrl } else { '' } $evidenceUris = @($buildUrl, $buildLogsUrl, $commitUrl | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique) $baselineTags = [System.Collections.Generic.List[string]]::new() if (-not [string]::IsNullOrWhiteSpace($correlationStatus)) { $baselineTags.Add($correlationStatus.ToLowerInvariant()) } if (-not [string]::IsNullOrWhiteSpace($secretCategory)) { $safeSecretCategory = ($secretCategory.ToLowerInvariant() -replace '[^a-z0-9\-_]+', '-').Trim('-') if ($safeSecretCategory) { $baselineTags.Add("secret-category:$safeSecretCategory") } } $pipelineSegments = @($entityId -split '/') $pipelineIdentifier = if ($pipelineSegments.Count -ge 4) { $pipelineSegments[$pipelineSegments.Count - 1] } else { 'unknown' } $entityRefs = @( $(if ($secretFindingId) { "finding:$secretFindingId" } else { $null }), "pipeline:$entityId", $(if ($buildId) { "build:$buildId" } else { $null }), $(if ($repositoryCanonicalId) { "repository:$repositoryCanonicalId" } else { $null }), $(if ($commitSha) { "commit:$commitSha" } else { $null }), "AzureDevOps|Pipeline|$($org.ToLowerInvariant())/$($project.ToLowerInvariant())/Pipeline/$pipelineIdentifier" ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique $buildKey = if ($buildId) { $buildId } else { 'none' } $secretKey = if ($secretFindingId) { $secretFindingId } else { 'none' } $baseTitle = if ($finding.PSObject.Properties['Title'] -and $finding.Title) { [string]$finding.Title } else { 'ADO pipeline secret correlation' } $title = if ($baseTitle -match '\[build:[^\]]+ secret:[^\]]+\]') { $baseTitle } else { "$baseTitle [build:$buildKey secret:$secretKey]" } $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } elseif ($ToolResult.PSObject.Properties['ToolVersion'] -and $ToolResult.ToolVersion) { [string]$ToolResult.ToolVersion } else { '' } $remediationSnippet = [ordered]@{ language = 'text' code = 'Audit pipeline variable groups, verify service connections used by this build, and review downstream artifact consumers before rotating and revoking exposed credentials.' } $row = New-FindingRow -Id ([string]$finding.Id) ` -Source 'ado-pipeline-correlator' -EntityId $entityId -EntityType 'Pipeline' ` -Title $title -Compliant ([bool]$finding.Compliant) -ProvenanceRunId $runId ` -Platform 'ADO' -Category 'Pipeline Run Correlation' -Severity $severity ` -Detail ([string]$finding.Detail) -Remediation ([string]$finding.Remediation) ` -LearnMoreUrl ([string]$finding.LearnMoreUrl) -ResourceId ([string]$finding.ResourceId) ` -Pillar 'Security' -Impact $impact -Effort 'Medium' ` -DeepLinkUrl $buildResultUrl -RemediationSnippets @($remediationSnippet) ` -EvidenceUris @($evidenceUris) -BaselineTags @($baselineTags.ToArray()) ` -EntityRefs @($entityRefs) -ToolVersion $toolVersion if ($null -ne $row) { $normalized.Add($row) } } return @($normalized) } |