modules/normalizers/Normalize-ADOPipelineSecurity.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Normalizer for ADO pipeline security findings. .DESCRIPTION Converts raw ADO pipeline security wrapper output into v2 FindingRow objects. Uses first-class ADO entity types for pipelines, variable groups, environments, and service connections when the wrapper provides that asset metadata. #> [CmdletBinding()] param () . "$PSScriptRoot\..\shared\Schema.ps1" . "$PSScriptRoot\..\shared\Canonicalize.ps1" function Normalize-ADOPipelineSecurity { [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject] $ToolResult, [System.Collections.Generic.List[psobject]] $EdgeCollector ) if ($ToolResult.Status -notin @('Success', 'PartialSuccess') -or -not $ToolResult.Findings) { return @() } $runId = [guid]::NewGuid().ToString() $normalized = [System.Collections.Generic.List[PSCustomObject]]::new() function Add-ADOPipelineTrackAEdges { param([object] $Candidate) if ($null -eq $EdgeCollector) { return } if ($null -eq $Candidate -or -not $Candidate.PSObject.Properties['AttackPathEdges']) { return } $allowedRelations = @('TriggeredBy', 'AuthenticatesAs', 'DeploysTo', 'UsesSecret', 'HasFederatedCredential', 'Declares') foreach ($edgeHint in @($Candidate.AttackPathEdges)) { if ($null -eq $edgeHint) { continue } $source = if ($edgeHint.PSObject.Properties['Source']) { [string]$edgeHint.Source } else { '' } $target = if ($edgeHint.PSObject.Properties['Target']) { [string]$edgeHint.Target } else { '' } $relation = if ($edgeHint.PSObject.Properties['Relation']) { [string]$edgeHint.Relation } else { '' } if ([string]::IsNullOrWhiteSpace($source) -or [string]::IsNullOrWhiteSpace($target)) { continue } if ($relation -notin $allowedRelations) { continue } $edge = New-Edge -Source $source -Target $target -Relation $relation -Confidence 'Likely' -Platform 'ADO' -DiscoveredBy 'ado-pipelines' if ($null -ne $edge) { $EdgeCollector.Add($edge) | Out-Null } } } function ConvertTo-StringArray { param ([object] $Value) if ($null -eq $Value) { return @() } if ($Value -is [string]) { if ([string]::IsNullOrWhiteSpace($Value)) { return @() } return @($Value.Trim()) } if ($Value -is [System.Collections.IEnumerable]) { return @($Value | ForEach-Object { if ($null -eq $_) { return } $candidate = [string]$_ if (-not [string]::IsNullOrWhiteSpace($candidate)) { $candidate.Trim() } } | Select-Object -Unique) } return @([string]$Value) } function ConvertTo-Snippets { param ([object] $Value) if ($null -eq $Value) { return @() } $snippets = [System.Collections.Generic.List[hashtable]]::new() foreach ($snippet in @($Value)) { if ($null -eq $snippet) { continue } $language = if ($snippet.PSObject.Properties['language'] -and $snippet.language) { [string]$snippet.language } else { 'bash' } $content = if ($snippet.PSObject.Properties['content'] -and $snippet.content) { [string]$snippet.content } elseif ($snippet.PSObject.Properties['code'] -and $snippet.code) { [string]$snippet.code } else { '' } if ([string]::IsNullOrWhiteSpace($content)) { continue } $snippets.Add(@{ language = $language.Trim().ToLowerInvariant() content = $content.Trim() }) | Out-Null } return @($snippets) } 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 } foreach ($finding in $ToolResult.Findings) { Add-ADOPipelineTrackAEdges -Candidate $finding $assetType = if ($finding.PSObject.Properties['AssetType'] -and $finding.AssetType) { [string]$finding.AssetType } else { 'BuildDefinition' } $entityType = $assetType $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' } $assetId = if ($finding.PSObject.Properties['AssetId'] -and $finding.AssetId) { [string]$finding.AssetId } else { '' } if ([string]::IsNullOrWhiteSpace($assetId)) { $assetId = if ($finding.PSObject.Properties['AssetName'] -and $finding.AssetName) { [string]$finding.AssetName } else { [guid]::NewGuid().ToString() } } $canonicalId = "$org/$project/$assetType/$assetId".ToLowerInvariant() $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) { [string]$finding.Id } else { [guid]::NewGuid().ToString() } $severity = if ($finding.PSObject.Properties['Severity'] -and $finding.Severity) { switch -Regex ([string]$finding.Severity) { '^(?i)critical$' { 'Critical' } '^(?i)high$' { 'High' } '^(?i)medium$' { 'Medium' } '^(?i)low$' { 'Low' } '^(?i)info' { 'Info' } default { 'Info' } } } else { 'Info' } $resourceId = if ($finding.PSObject.Properties['ResourceId'] -and $finding.ResourceId) { [string]$finding.ResourceId } else { '' } $ruleId = if ($finding.PSObject.Properties['RuleId'] -and $finding.RuleId) { [string]$finding.RuleId } else { '' } $baselineTags = [System.Collections.Generic.List[string]]::new() $baselineTags.Add("Asset-$assetType") | Out-Null $controlTag = Get-ControlTagFromRuleId -RuleId $ruleId if (-not [string]::IsNullOrWhiteSpace($controlTag)) { $baselineTags.Add($controlTag) | Out-Null } $entityRefs = ConvertTo-StringArray -Value $(if ($finding.PSObject.Properties['EntityRefs']) { $finding.EntityRefs } else { @() }) $evidenceUris = ConvertTo-StringArray -Value $(if ($finding.PSObject.Properties['EvidenceUris']) { $finding.EvidenceUris } else { @() }) $remediationSnippets = ConvertTo-Snippets -Value $(if ($finding.PSObject.Properties['RemediationSnippets']) { $finding.RemediationSnippets } else { @() }) $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } else { 'unknown' } $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 { '' } $row = New-FindingRow -Id $findingId ` -Source 'ado-pipelines' -EntityId $canonicalId -EntityType $entityType ` -Title ([string]$finding.Title) -RuleId $ruleId -Compliant ([bool]$finding.Compliant) -ProvenanceRunId $runId ` -Platform 'AzureDevOps' -Category ([string]$finding.Category) -Severity $severity ` -Detail ([string]$finding.Detail) -Remediation ([string]$finding.Remediation) ` -LearnMoreUrl ([string]$finding.LearnMoreUrl) -ResourceId $resourceId ` -Pillar $pillar -Impact $impact -Effort $effort -DeepLinkUrl $deepLinkUrl ` -RemediationSnippets $remediationSnippets -EvidenceUris $evidenceUris ` -Frameworks @() -MitreTactics @() -MitreTechniques @() ` -BaselineTags @($baselineTags | Select-Object -Unique) -EntityRefs $entityRefs ` -ToolVersion $toolVersion if ($null -ne $row) { $normalized.Add($row) } } return @($normalized) } |