modules/normalizers/Normalize-Scorecard.ps1
|
#Requires -Version 7.4 <# .SYNOPSIS Normalizer for OpenSSF Scorecard findings. .DESCRIPTION Converts raw Scorecard wrapper output to v3 FindingRow objects. Platform=GitHub, EntityType=Repository. #> [CmdletBinding()] param () . "$PSScriptRoot\..\shared\Schema.ps1" . "$PSScriptRoot\..\shared\Canonicalize.ps1" function ConvertTo-StringArray { param ([object] $InputObject) if ($null -eq $InputObject) { return @() } if ($InputObject -is [string]) { return @([string]$InputObject) } $output = [System.Collections.Generic.List[string]]::new() foreach ($item in @($InputObject)) { if ($null -eq $item) { continue } $text = [string]$item if (-not [string]::IsNullOrWhiteSpace($text)) { $output.Add($text.Trim()) } } return $output.ToArray() } function Get-ScorecardSeverityFromScore { param ([Nullable[int]] $Score) if ($null -eq $Score) { return $null } if ($Score -eq -1) { return 'Info' } $scoreValue = [int]$Score if ($scoreValue -le 2) { return 'Critical' } if ($scoreValue -le 5) { return 'High' } if ($scoreValue -le 7) { return 'Medium' } if ($scoreValue -le 9) { return 'Low' } return 'Info' } function Get-ScorecardRepoWebBaseUrl { param ([string] $ResourceId) if ([string]::IsNullOrWhiteSpace($ResourceId)) { return $null } $clean = $ResourceId.Trim() -replace '^https?://', '' if ($clean -notmatch '^([^/]+)/([^/]+)/([^/]+)$') { return $null } $repoHost = $matches[1] $owner = $matches[2] $repo = $matches[3] return "https://$repoHost/$owner/$repo" } function Get-ScorecardEvidenceUris { param ( [object] $CheckDetails, [string] $ResourceId ) $details = ConvertTo-StringArray -InputObject $CheckDetails if (@($details).Count -eq 0) { return @() } $repoBaseUrl = Get-ScorecardRepoWebBaseUrl -ResourceId $ResourceId $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $uris = [System.Collections.Generic.List[string]]::new() foreach ($detail in $details) { foreach ($urlMatch in [System.Text.RegularExpressions.Regex]::Matches($detail, 'https?://[^\s\)\]]+')) { $value = [string]$urlMatch.Value if ($seen.Add($value)) { $uris.Add($value) } } if ($repoBaseUrl) { foreach ($shaMatch in [System.Text.RegularExpressions.Regex]::Matches($detail, '(?<![0-9a-fA-F])([0-9a-fA-F]{7,40})(?![0-9a-fA-F])')) { $sha = [string]$shaMatch.Groups[1].Value $commitUrl = "$repoBaseUrl/commit/$sha" if ($seen.Add($commitUrl)) { $uris.Add($commitUrl) } } foreach ($pathMatch in [System.Text.RegularExpressions.Regex]::Matches($detail, '(?<![A-Za-z0-9_\-./])(\.?[A-Za-z0-9_\-]+(?:/[A-Za-z0-9_\-\.]+)+\.[A-Za-z0-9_\-]+)(?![A-Za-z0-9_\-./])')) { $path = ([string]$pathMatch.Groups[1].Value).TrimStart('/') if ([string]::IsNullOrWhiteSpace($path)) { continue } $fileUrl = "$repoBaseUrl/blob/HEAD/$($path -replace ' ', '%20')" if ($seen.Add($fileUrl)) { $uris.Add($fileUrl) } } } } return $uris.ToArray() } function ConvertTo-HashtableArray { param ([object] $InputObject) if ($null -eq $InputObject) { return @() } $result = [System.Collections.Generic.List[hashtable]]::new() foreach ($item in @($InputObject)) { if ($null -eq $item) { continue } if ($item -is [hashtable]) { $result.Add($item) continue } $hash = @{} foreach ($property in $item.PSObject.Properties) { $hash[$property.Name] = $property.Value } $result.Add($hash) } return $result.ToArray() } function Normalize-Scorecard { [CmdletBinding()] param ( [Parameter(Mandatory)] [PSCustomObject] $ToolResult ) if ($ToolResult.Status -ne 'Success' -or -not $ToolResult.Findings) { return @() } $runId = [guid]::NewGuid().ToString() $normalized = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($finding in $ToolResult.Findings) { $rawId = '' if ($finding.PSObject.Properties['ResourceId'] -and $finding.ResourceId) { $rawId = [string]$finding.ResourceId } # Try to canonicalize as a GitHub repo ID $canonicalId = '' if ($rawId) { try { $canonicalId = ConvertTo-CanonicalRepoId -RepoId $rawId } catch { # If it doesn't parse as a repo, derive host from URL or default to github.com $repoHost = 'github.com' $cleaned = $rawId -replace '^https?://', '' if ($cleaned -match '^([^/]+)/') { $candidateHost = $matches[1].ToLowerInvariant() if ($candidateHost -ne 'github.com' -and $candidateHost -match '\.') { $repoHost = $candidateHost } } $canonicalId = "$repoHost/$($rawId.TrimStart('/').ToLowerInvariant())" } } $findingId = if ($finding.PSObject.Properties['Id'] -and $finding.Id) { [string]$finding.Id } else { [guid]::NewGuid().ToString() } if (-not $canonicalId) { $canonicalId = "scorecard/$findingId" } $title = if ($finding.PSObject.Properties['Title'] -and $finding.Title) { $finding.Title } else { 'Unknown' } $category = if ($finding.PSObject.Properties['Category'] -and $finding.Category) { $finding.Category } else { 'Supply Chain' } $score = $null if ($finding.PSObject.Properties['Score'] -and $null -ne $finding.Score) { $parsedScore = 0 if ([int]::TryParse([string]$finding.Score, [ref]$parsedScore)) { $score = $parsedScore } } elseif ($finding.PSObject.Properties['Detail'] -and $finding.Detail -and ([string]$finding.Detail -match 'Score\s+(-?\d+)\/10')) { $parsedScore = 0 if ([int]::TryParse($matches[1], [ref]$parsedScore)) { $score = $parsedScore } } $severity = Get-ScorecardSeverityFromScore -Score $score if (-not $severity) { $rawSev = if ($finding.PSObject.Properties['Severity'] -and $finding.Severity) { $finding.Severity } else { 'Medium' } $severity = switch -Regex ($rawSev.ToString().ToLowerInvariant()) { 'critical' { 'Critical' } 'high' { 'High' } 'medium|moderate' { 'Medium' } 'low' { 'Low' } 'info' { 'Info' } default { 'Medium' } } } $compliant = if ($finding.PSObject.Properties['Compliant']) { [bool]$finding.Compliant } else { $true } $detail = if ($finding.PSObject.Properties['Detail'] -and $finding.Detail) { $finding.Detail } else { '' } $remediation = if ($finding.PSObject.Properties['Remediation'] -and $finding.Remediation) { $finding.Remediation } else { '' } $learnMore = if ($finding.PSObject.Properties['LearnMoreUrl'] -and $finding.LearnMoreUrl) { $finding.LearnMoreUrl } else { '' } $frameworks = if ($finding.PSObject.Properties['Frameworks'] -and $finding.Frameworks) { @($finding.Frameworks) } else { @() } $pillar = if ($finding.PSObject.Properties['Pillar'] -and $finding.Pillar) { [string]$finding.Pillar } else { 'Security' } $deepLinkUrl = if ($finding.PSObject.Properties['DeepLinkUrl'] -and $finding.DeepLinkUrl) { [string]$finding.DeepLinkUrl } else { '' } $remediationSnippets = if ($finding.PSObject.Properties['RemediationSnippets'] -and $finding.RemediationSnippets) { ConvertTo-HashtableArray -InputObject $finding.RemediationSnippets } else { @() } $baselineTags = if ($finding.PSObject.Properties['BaselineTags'] -and $finding.BaselineTags) { ConvertTo-StringArray -InputObject $finding.BaselineTags } else { @() } $toolVersion = if ($finding.PSObject.Properties['ToolVersion'] -and $finding.ToolVersion) { [string]$finding.ToolVersion } else { '' } $checkDetails = if ($finding.PSObject.Properties['CheckDetails']) { $finding.CheckDetails } else { $null } $evidenceUris = Get-ScorecardEvidenceUris -CheckDetails $checkDetails -ResourceId $rawId # Track D enrichment (#432b): derive Impact/Effort, surface ScoreDelta from # the OpenSSF score (10 - score), pass through MITRE, and seed EntityRefs # with the parent organisation derived from the canonical repo id. $impact = if ($finding.PSObject.Properties['Impact'] -and $finding.Impact) { [string]$finding.Impact } else { switch ($severity) { 'Critical' { 'High' } 'High' { 'High' } 'Medium' { 'Medium' } default { 'Low' } } } $effort = if ($finding.PSObject.Properties['Effort'] -and $finding.Effort) { [string]$finding.Effort } else { switch ($severity) { 'Critical' { 'Medium' } 'High' { 'Medium' } 'Medium' { 'Medium' } default { 'Low' } } } $scoreDelta = $null if ($finding.PSObject.Properties['ScoreDelta'] -and $null -ne $finding.ScoreDelta) { try { $scoreDelta = [double]$finding.ScoreDelta } catch { $scoreDelta = $null } } elseif ($null -ne $score -and $score -ge 0) { $scoreDelta = [double](10 - [int]$score) } $mitreTactics = if ($finding.PSObject.Properties['MitreTactics'] -and $finding.MitreTactics) { @([string[]]$finding.MitreTactics) } else { @() } $mitreTechniques = if ($finding.PSObject.Properties['MitreTechniques'] -and $finding.MitreTechniques) { @([string[]]$finding.MitreTechniques) } else { @() } $entityRefs = [System.Collections.Generic.List[string]]::new() if ($finding.PSObject.Properties['EntityRefs'] -and $finding.EntityRefs) { foreach ($r in @($finding.EntityRefs)) { if (-not [string]::IsNullOrWhiteSpace([string]$r)) { $entityRefs.Add([string]$r) | Out-Null } } } if ($canonicalId -match '^([^/]+)/([^/]+)/[^/]+$') { $orgRef = "$($Matches[1])/$($Matches[2])".ToLowerInvariant() if ($entityRefs -notcontains $orgRef) { $entityRefs.Add($orgRef) | Out-Null } } $row = New-FindingRow -Id $findingId ` -Source 'scorecard' -EntityId $canonicalId -EntityType 'Repository' ` -Title $title -Compliant ([bool]$compliant) -ProvenanceRunId $runId ` -Platform 'GitHub' -Category $category -Severity $severity ` -Detail $detail -Remediation $remediation ` -LearnMoreUrl $learnMore -ResourceId ($rawId ?? '') ` -Frameworks $frameworks -Pillar $pillar -DeepLinkUrl $deepLinkUrl ` -RemediationSnippets $remediationSnippets -EvidenceUris $evidenceUris ` -BaselineTags $baselineTags ` -Impact $impact -Effort $effort -ScoreDelta $scoreDelta ` -MitreTactics @($mitreTactics) -MitreTechniques @($mitreTechniques) ` -EntityRefs @($entityRefs) ` -ToolVersion $toolVersion # Skip null rows (validation failed) if ($null -ne $row) { $normalized.Add($row) } } return @($normalized) } |