modules/Invoke-Scorecard.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS Wrapper for OpenSSF Scorecard CLI. .DESCRIPTION Runs the scorecard CLI against a GitHub repository and returns supply chain security findings as PSObjects. If scorecard is not installed, writes a warning and returns an empty result. Never throws — designed for graceful degradation in the orchestrator. .PARAMETER Repository The repository to scan (e.g., "github.com/martinopedal/azure-analyzer"). Aliases: Repo, RepoUrl .PARAMETER Threshold Minimum score (0-10) to consider a check compliant. Default is 7. .PARAMETER GitHubHost Custom GitHub host for GHEC-DR or GHES (e.g., "github.contoso.com"). Sets GH_HOST environment variable for the scorecard CLI call. When empty, defaults to github.com. #> [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param ( [Parameter(Mandatory)] [Alias('Repo', 'RepoUrl')] [ValidateNotNullOrEmpty()] [string] $Repository, [ValidateRange(0, 10)] [int] $Threshold = 7, [string] $GitHubHost = 'github.com' ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1' if (Test-Path $sanitizePath) { . $sanitizePath } $missingToolPath = Join-Path $PSScriptRoot 'shared' 'MissingTool.ps1' if (Test-Path $missingToolPath) { . $missingToolPath } if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) { function Remove-Credentials { param([string]$Text) return $Text } } $errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1' if (Test-Path $errorsPath) { . $errorsPath } $envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1' if (Test-Path $envelopePath) { . $envelopePath } 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) } } } if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) { function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } } } if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) { function Format-FindingErrorMessage { param([Parameter(Mandatory)]$FindingError) $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" } return $line } } function Test-ScorecardInstalled { $null -ne (Get-Command scorecard -ErrorAction SilentlyContinue) } 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-ScorecardVersionData { $toolVersion = '' $releaseTag = '' try { $rawVersion = scorecard --version 2>&1 | Out-String $toolVersion = $rawVersion.Trim() if ($toolVersion -match '(v\d+\.\d+(?:\.\d+)?)') { $releaseTag = $matches[1] } } catch { Write-Verbose "Unable to read scorecard version: $($_.Exception.Message)" } return @{ ToolVersion = $toolVersion ReleaseTag = $releaseTag BaselineTags = if ($releaseTag) { @($releaseTag) } else { @() } } } function Get-ObjectPropertyValue { param ( [object] $Object, [string] $PropertyName ) if ($null -eq $Object -or [string]::IsNullOrWhiteSpace($PropertyName)) { return $null } if ($Object -is [System.Collections.IDictionary] -and $Object.Contains($PropertyName)) { return $Object[$PropertyName] } $property = $Object | Get-Member -Name $PropertyName -MemberType NoteProperty, Property -ErrorAction SilentlyContinue if ($property) { return $Object.$PropertyName } return $null } function ConvertTo-ScorecardCheckSlug { param ([string] $CheckName) if ([string]::IsNullOrWhiteSpace($CheckName)) { return '' } $slug = $CheckName.Trim().ToLowerInvariant() -replace '[^a-z0-9]+', '-' return $slug.Trim('-') } function Get-ScorecardDeepLinkUrl { param ([string] $CheckName) $slug = ConvertTo-ScorecardCheckSlug -CheckName $CheckName if (-not $slug) { return '' } return "https://github.com/ossf/scorecard/blob/main/docs/checks.md#$slug" } function Get-ScorecardSeverityFromScore { param ([int] $Score) if ($Score -eq -1) { return 'Info' } if ($Score -le 2) { return 'Critical' } if ($Score -le 5) { return 'High' } if ($Score -le 7) { return 'Medium' } if ($Score -le 9) { return 'Low' } return 'Info' } function Get-ScorecardCategory { param ([string] $CheckName) $normalized = (ConvertTo-ScorecardCheckSlug -CheckName $CheckName) $mappedCategory = switch ($normalized) { 'maintained' { 'Maintained' } 'code-review' { 'Code-Review' } 'sast' { 'SAST' } 'dependencies' { 'Dependencies' } 'pinned-dependencies' { 'Dependencies' } 'branch-protection' { 'Code-Review' } 'security-policy' { 'Security-Policy' } 'token-permissions' { 'Token-Permissions' } 'signed-releases' { 'Signed-Releases' } 'dangerous-workflow' { 'Dangerous-Workflow' } 'binary-artifacts' { 'Binary-Artifacts' } 'packaging' { 'Packaging' } default { 'Supply Chain' } } return $mappedCategory } function Get-ScorecardFrameworks { param ([string] $CheckName) $frameworks = [System.Collections.Generic.List[hashtable]]::new() $frameworks.Add(@{ Name = 'OpenSSF Scorecard' Controls = @($CheckName) }) $slsaControlMap = @{ 'binary-artifacts' = @('Build L3', 'Provenance L3') 'branch-protection' = @('Source L3') 'code-review' = @('Source L3') 'ci-tests' = @('Build L3') 'packaging' = @('Provenance L3') 'pinned-dependencies'= @('Build L3') 'signed-releases' = @('Provenance L3') 'token-permissions' = @('Build L3') } $normalized = ConvertTo-ScorecardCheckSlug -CheckName $CheckName if ($slsaControlMap.ContainsKey($normalized)) { $frameworks.Add(@{ Name = 'SLSA' Controls = @($slsaControlMap[$normalized]) }) } return $frameworks.ToArray() } function Get-ScorecardRemediationSnippets { param ([object] $Documentation) if ($null -eq $Documentation) { return @() } $snippets = [System.Collections.Generic.List[hashtable]]::new() foreach ($propertyName in @('text', 'short', 'details', 'remediation')) { $value = [string](Get-ObjectPropertyValue -Object $Documentation -PropertyName $propertyName) if ([string]::IsNullOrWhiteSpace($value)) { continue } $snippets.Add(@{ Kind = 'Documentation' Title = $propertyName Content = $value.Trim() }) } return $snippets.ToArray() } if (-not (Test-ScorecardInstalled)) { $missingMessage = "scorecard is not installed. Skipping Scorecard scan. Install from https://github.com/ossf/scorecard/releases" Write-MissingToolNotice -Tool 'scorecard' -Message $missingMessage return [PSCustomObject]@{ Source = 'scorecard' SchemaVersion = '1.0' Status = 'Skipped' Message = 'scorecard CLI not installed. Download from https://github.com/ossf/scorecard/releases' Findings = @() Errors = @() Diagnostics = @( [PSCustomObject]@{ Code = 'MissingTool' Tool = 'scorecard' Message = $missingMessage } ) } } # #768: scorecard requires a GitHub token for meaningful results (rate-limited # unauthenticated calls produce noisy/incomplete data). If no token is present, # return Status=Skipped with a clear remediation message rather than running # degraded. We accept both GITHUB_AUTH_TOKEN (scorecard's native variable) and # GITHUB_TOKEN (the GitHub Actions / gh-cli convention), mirroring the resolution # order used by RemoteClone.ps1. $resolvedAuthToken = if ($env:GITHUB_AUTH_TOKEN) { $env:GITHUB_AUTH_TOKEN } elseif ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } else { '' } if (-not $resolvedAuthToken) { $skipMessage = 'scorecard requires a GitHub auth token. Set GITHUB_AUTH_TOKEN or GITHUB_TOKEN (e.g. `gh auth token`) and retry.' Write-Verbose $skipMessage return [PSCustomObject]@{ Source = 'scorecard' SchemaVersion = '1.0' Status = 'Skipped' Message = $skipMessage Findings = @() Errors = @() Diagnostics = @( [PSCustomObject]@{ Code = 'MissingAuthToken' Tool = 'scorecard' Message = $skipMessage } ) } } try { $versionData = Get-ScorecardVersionData # Set GH_HOST for GHEC-DR / GHES, preserving the original value $originalGhHost = $env:GH_HOST if ($GitHubHost) { Write-Verbose "Setting GH_HOST=$GitHubHost for enterprise GitHub instance" $env:GH_HOST = $GitHubHost } try { Write-Verbose "Running scorecard for repository $Repository (threshold=$Threshold)" # Real scorecard CLIs are external binaries; Pester mocks register it as a PS function. # For external binaries, run under a hard 300s Start-Job / Wait-Job timeout. # For functions/cmdlets (tests), call directly — no hang risk and Start-Job can't see in-process mocks. $scCmd = Get-Command scorecard -ErrorAction SilentlyContinue if ($scCmd -and $scCmd.CommandType -eq 'Application') { $scorecardJob = Start-Job -ScriptBlock { param($repo, $ghHost, $authToken) if ($ghHost) { $env:GH_HOST = $ghHost } # #768: scorecard reads GITHUB_AUTH_TOKEN; propagate the resolved token # into the job environment (jobs do not inherit env automatically). if ($authToken) { $env:GITHUB_AUTH_TOKEN = $authToken } scorecard --repo=$repo --format=json 2>&1 } -ArgumentList $Repository, $GitHubHost, $resolvedAuthToken if (Wait-Job -Job $scorecardJob -Timeout 300) { $rawOutput = Receive-Job -Job $scorecardJob } else { Stop-Job -Job $scorecardJob -ErrorAction SilentlyContinue Remove-Job -Job $scorecardJob -Force -ErrorAction SilentlyContinue throw (Format-FindingErrorMessage (New-FindingError ` -Source 'wrapper:scorecard' ` -Category 'TimeoutExceeded' ` -Reason "scorecard CLI timed out after 300 seconds for repo $(Remove-Credentials -Text ([string]$Repository))." ` -Remediation 'Retry on a smaller repo scope or increase the scorecard timeout.')) } Remove-Job -Job $scorecardJob -Force -ErrorAction SilentlyContinue } else { # In-process mock path: ensure GITHUB_AUTH_TOKEN is set so the wrapper's # contract (#768) is observable from tests too. $originalAuthToken = $env:GITHUB_AUTH_TOKEN try { if (-not $env:GITHUB_AUTH_TOKEN) { $env:GITHUB_AUTH_TOKEN = $resolvedAuthToken } $rawOutput = scorecard --repo=$Repository --format=json 2>&1 } finally { if ($null -eq $originalAuthToken) { Remove-Item Env:\GITHUB_AUTH_TOKEN -ErrorAction SilentlyContinue } else { $env:GITHUB_AUTH_TOKEN = $originalAuthToken } } } $json = $rawOutput | Out-String | ConvertFrom-Json -ErrorAction Stop } finally { # Restore original GH_HOST if ($GitHubHost) { if ($null -eq $originalGhHost) { Remove-Item Env:\GH_HOST -ErrorAction SilentlyContinue } else { $env:GH_HOST = $originalGhHost } } } $findings = [System.Collections.Generic.List[PSCustomObject]]::new() $repoName = ($Repository -replace '^https?://', '').Trim('/').ToLowerInvariant() if ($json.repo -and $json.repo.name) { $repoValue = ([string]$json.repo.name).Trim('/').ToLowerInvariant() if ($repoValue -match '^[^/]+/[^/]+$') { $repoName = "$($GitHubHost.ToLowerInvariant())/$repoValue" } elseif ($repoValue -match '^[^/]+\.[^/]+/[^/]+/[^/]+$') { $repoName = $repoValue } } if ($json.checks) { foreach ($check in $json.checks) { $score = -1 $rawScore = Get-ObjectPropertyValue -Object $check -PropertyName 'score' if ($null -ne $rawScore) { $parsedScore = 0 if ([int]::TryParse([string]$rawScore, [ref]$parsedScore)) { $score = $parsedScore } } $checkName = if ($check.name) { [string]$check.name } else { 'Unknown' } $severity = Get-ScorecardSeverityFromScore -Score $score $compliant = ($score -ge $Threshold) -and ($score -ge 0) $reason = if ($check.reason) { [string]$check.reason } else { '' } $detail = if ([string]::IsNullOrWhiteSpace($reason)) { "Score $score/10." } else { "Score $score/10. $reason" } $deepLinkUrl = Get-ScorecardDeepLinkUrl -CheckName $checkName $documentationUrl = Get-ObjectPropertyValue -Object $check.documentation -PropertyName 'url' $learnMoreUrl = if ($documentationUrl) { [string]$documentationUrl } else { $deepLinkUrl } $remediationSnippets = Get-ScorecardRemediationSnippets -Documentation $check.documentation $remediation = '' if (@($remediationSnippets).Count -gt 0) { $firstSnippet = $remediationSnippets[0] if ($firstSnippet -is [System.Collections.IDictionary] -and $firstSnippet.Contains('Content')) { $remediation = [string]$firstSnippet['Content'] } elseif ($null -ne (Get-ObjectPropertyValue -Object $firstSnippet -PropertyName 'Content')) { $remediation = [string]$firstSnippet.Content } } $checkDetails = ConvertTo-StringArray -InputObject $check.details $findings.Add([PSCustomObject]@{ Id = [guid]::NewGuid().ToString() Category = Get-ScorecardCategory -CheckName $checkName Title = $checkName Severity = $severity Compliant = $compliant Detail = $detail Remediation = $remediation ResourceId = $repoName LearnMoreUrl = $learnMoreUrl Score = $score CheckName = $checkName CheckDetails = $checkDetails Frameworks = Get-ScorecardFrameworks -CheckName $checkName Pillar = 'Security' DeepLinkUrl = $deepLinkUrl RemediationSnippets = $remediationSnippets BaselineTags = @($versionData.BaselineTags) ToolVersion = [string]$versionData.ToolVersion }) } } return [PSCustomObject]@{ Source = 'scorecard' SchemaVersion = '1.0' Status = 'Success' Message = '' Findings = @($findings) Errors = @() } } catch { Write-Warning "Scorecard scan failed: $(Remove-Credentials -Text ([string]$_))" return [PSCustomObject]@{ Source = 'scorecard' SchemaVersion = '1.0' Status = 'Failed' Message = Remove-Credentials -Text ([string]$_) Findings = @() Errors = @() } } |