Files
dependency-review-action/src/main.ts
Eric Sorenson 57a3d46a7b Merge pull request #1060 from jantiebot/main
fix: only get scorecard levels if user wants to see the OpenSSF scorecard
2026-02-27 15:05:18 -08:00

532 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as core from '@actions/core'
import * as dependencyGraph from './dependency-graph'
import * as github from '@actions/github'
import styles from 'ansi-styles'
import {RequestError} from '@octokit/request-error'
import {
Change,
Severity,
Changes,
ConfigurationOptions,
Scorecard
} from './schemas'
import {readConfig} from '../src/config'
import {
filterChangesBySeverity,
filterChangesByScopes,
filterAllowedAdvisories
} from '../src/filter'
import {getInvalidLicenseChanges} from './licenses'
import {getScorecardLevels} from './scorecard'
import * as summary from './summary'
import {getRefs} from './git-refs'
import {groupDependenciesByManifest} from './utils'
import {commentPr, MAX_COMMENT_LENGTH} from './comment-pr'
import {getDeniedChanges} from './deny'
import {DefaultArtifactClient} from '@actions/artifact'
import * as fs from 'fs'
import type {PayloadRepository} from '@actions/github/lib/interfaces.d'
async function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function getComparison(
baseRef: string,
headRef: string,
retryOpts?: {
retryUntil: number
retryDelay: number
}
): ReturnType<typeof dependencyGraph.compare> {
const comparison = await dependencyGraph.compare({
owner: github.context.repo.owner,
repo: github.context.repo.repo,
baseRef,
headRef
})
if (comparison.snapshot_warnings.trim() !== '') {
core.info(comparison.snapshot_warnings)
if (retryOpts !== undefined) {
if (retryOpts.retryUntil < Date.now()) {
core.info(`Retry timeout exceeded. Proceeding...`)
return comparison
} else {
core.info(`Retrying in ${retryOpts.retryDelay} seconds...`)
await delay(retryOpts.retryDelay * 1000)
return getComparison(baseRef, headRef, retryOpts)
}
}
}
return comparison
}
export async function handleLargeSummary(
summaryContent: string
): Promise<string> {
const MAX_SUMMARY_SIZE = 1024 * 1024 // 1024k in bytes
if (Buffer.byteLength(summaryContent, 'utf8') <= MAX_SUMMARY_SIZE) {
return summaryContent
}
const summarySize = Math.round(
Buffer.byteLength(summaryContent, 'utf8') / 1024
)
const truncatedSummary = `# Dependency Review Summary
The full dependency review summary was too large to display here (${summarySize}KB, limit is 1024KB).`
const artifactClient = new DefaultArtifactClient()
const artifactName = 'dependency-review-summary'
const files = ['summary.md']
try {
// Write the summary to a file
await fs.promises.writeFile('summary.md', summaryContent)
// Upload the artifact
await artifactClient.uploadArtifact(artifactName, files, '.', {
retentionDays: 1
})
// Return a shorter summary with a link to the artifact
const shortSummary = `${truncatedSummary}
Please download the artifact named "${artifactName}" to view the complete report.
[View full job summary](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`
// Set core.summary to the shorter summary value to avoid exceeding MAX_SUMMARY_SIZE
core.summary.emptyBuffer()
core.summary.addRaw(shortSummary)
return shortSummary
} catch (error) {
core.warning(
`Failed to upload large summary as artifact: ${error instanceof Error ? error.message : 'Unknown error'}`
)
// Even though artifact upload failed, we must still replace the buffer
// with a truncated summary to prevent core.summary.write() from failing
// with the oversized content (see issue #867)
core.summary.emptyBuffer()
core.summary.addRaw(truncatedSummary)
return truncatedSummary
}
}
interface RepoWithPrivate extends PayloadRepository {
private: boolean
}
async function run(): Promise<void> {
try {
const config = await readConfig()
const refs = getRefs(config, github.context)
const comparison = await getComparison(
refs.base,
refs.head,
config.retry_on_snapshot_warnings
? {
retryUntil:
Date.now() + config.retry_on_snapshot_warnings_timeout * 1000,
retryDelay: 10
}
: undefined
)
const changes = comparison.changes
const snapshot_warnings = comparison.snapshot_warnings
if (!changes) {
core.info('No Dependency Changes found. Skipping Dependency Review.')
return
}
const scopedChanges = filterChangesByScopes(config.fail_on_scopes, changes)
const filteredChanges = filterAllowedAdvisories(
config.allow_ghsas,
scopedChanges
)
const failOnSeverityParams = config.fail_on_severity
const warnOnly = config.warn_only
let minSeverity: Severity = 'low'
// If failOnSeverityParams is not set or warnOnly is true, the minSeverity is low, to allow all vulnerabilities to be reported as warnings
if (failOnSeverityParams && !warnOnly) {
minSeverity = failOnSeverityParams
}
const vulnerableChanges = filterChangesBySeverity(
minSeverity,
filteredChanges
)
const invalidLicenseChanges = await getInvalidLicenseChanges(
filteredChanges,
{
allow: config.allow_licenses,
deny: config.deny_licenses,
licenseExclusions: config.allow_dependencies_licenses
}
)
core.debug(`Filtered Changes: ${JSON.stringify(filteredChanges)}`)
core.debug(`Config Deny Packages: ${JSON.stringify(config)}`)
const deniedChanges = await getDeniedChanges(
filteredChanges,
config.deny_packages,
config.deny_groups
)
// generate informational scorecard entries for all added changes in the PR
let scorecard: Scorecard = {dependencies: []}
if (config.show_openssf_scorecard) {
const scorecardChanges = getScorecardChanges(changes)
scorecard = await getScorecardLevels(scorecardChanges)
}
const minSummary = summary.addSummaryToSummary(
vulnerableChanges,
invalidLicenseChanges,
deniedChanges,
scorecard,
config
)
if (snapshot_warnings) {
summary.addSnapshotWarnings(config, snapshot_warnings)
}
let issueFound = false
if (config.vulnerability_check) {
core.setOutput('vulnerable-changes', JSON.stringify(vulnerableChanges))
await summary.addChangeVulnerabilitiesToSummary(
vulnerableChanges,
minSeverity,
config.show_patched_versions
)
issueFound ||= await printVulnerabilitiesBlock(
vulnerableChanges,
minSeverity,
warnOnly
)
}
if (config.license_check) {
core.setOutput(
'invalid-license-changes',
JSON.stringify(invalidLicenseChanges)
)
summary.addLicensesToSummary(invalidLicenseChanges, config)
issueFound ||= await printLicensesBlock(invalidLicenseChanges, warnOnly)
}
if (config.deny_packages || config.deny_groups) {
core.setOutput('denied-changes', JSON.stringify(deniedChanges))
summary.addDeniedToSummary(deniedChanges)
issueFound ||= await printDeniedDependencies(deniedChanges, config)
}
if (config.show_openssf_scorecard) {
summary.addScorecardToSummary(scorecard, config)
printScorecardBlock(scorecard, config)
createScorecardWarnings(scorecard, config)
}
core.setOutput('dependency-changes', JSON.stringify(changes))
summary.addScannedFiles(changes)
printScannedDependencies(changes)
// include full summary in output; Actions will truncate if oversized
let rendered = core.summary.stringify()
core.setOutput('comment-content', rendered)
// Handle large summaries by uploading as artifact
rendered = await handleLargeSummary(rendered)
// if the summary is oversized, replace with minimal version
if (rendered.length >= MAX_COMMENT_LENGTH) {
core.debug(
'The comment was too big for the GitHub API. Falling back on a minimum comment'
)
rendered = minSummary
}
// update the PR comment if needed with the right-sized summary
await commentPr(rendered, config, issueFound)
} catch (error) {
if (error instanceof RequestError && error.status === 404) {
core.setFailed(
`Dependency review could not obtain dependency data for the specified owner, repository, or revision range.`
)
} else if (error instanceof RequestError && error.status === 403) {
let repoIsPrivate = false
if ('repository' in github.context.payload) {
const repo = github.context.payload.repository as RepoWithPrivate
repoIsPrivate = repo.private
}
if (repoIsPrivate) {
core.setFailed(
`Dependency review is not supported on this repository. Please ensure that Dependency graph is enabled along with GitHub Advanced Security, see ${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/settings/security_analysis`
)
} else {
core.setFailed(
`Dependency review is not supported on this repository. Please ensure that Dependency graph is enabled, see ${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/settings/security_analysis`
)
}
} else {
if (error instanceof Error) {
core.setFailed(error.message)
} else {
core.setFailed('Unexpected fatal error')
}
}
} finally {
try {
await core.summary.write()
} catch (error) {
core.warning(
`Failed to write job summary: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
}
async function printVulnerabilitiesBlock(
addedChanges: Changes,
minSeverity: Severity,
warnOnly: boolean
): Promise<boolean> {
return core.group('Vulnerabilities', async () => {
let vulnFound = false
for (const change of addedChanges) {
vulnFound ||= printChangeVulnerabilities(change)
}
if (vulnFound) {
const msg = 'Dependency review detected vulnerable packages.'
if (warnOnly) {
core.warning(msg)
} else {
core.setFailed(msg)
}
} else {
core.info(
`Dependency review did not detect any vulnerable packages with severity level "${minSeverity}" or higher.`
)
}
return vulnFound
})
}
function printChangeVulnerabilities(change: Change): boolean {
for (const vuln of change.vulnerabilities) {
core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${
change.version
}${styles.bold.close} ${vuln.advisory_summary} ${renderSeverity(
vuln.severity
)}`
)
core.info(`${vuln.advisory_url}`)
}
return change.vulnerabilities.length > 0
}
async function printLicensesBlock(
invalidLicenseChanges: Record<string, Changes>,
warnOnly: boolean
): Promise<boolean> {
return core.group('Licenses', async () => {
let issueFound = false
if (invalidLicenseChanges.forbidden.length > 0) {
issueFound = true
core.info('\nThe following dependencies have incompatible licenses:')
printLicensesError(invalidLicenseChanges.forbidden)
const msg = 'Dependency review detected incompatible licenses.'
if (warnOnly) {
core.warning(msg)
} else {
core.setFailed(msg)
}
}
if (invalidLicenseChanges.unresolved.length > 0) {
issueFound = true
core.warning(
'\nThe validity of the licenses of the dependencies below could not be determined. Ensure that they are valid SPDX licenses:'
)
printLicensesError(invalidLicenseChanges.unresolved)
core.setFailed(
'Dependency review could not detect the validity of all licenses.'
)
}
printNullLicenses(invalidLicenseChanges.unlicensed)
return issueFound
})
}
function printLicensesError(changes: Changes): void {
for (const change of changes) {
core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close} License: ${styles.color.red.open}${change.license}${styles.color.red.close}`
)
}
}
function printNullLicenses(changes: Changes): void {
if (changes.length === 0) {
return
}
core.info('\nWe could not detect a license for the following dependencies:')
for (const change of changes) {
core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close}`
)
}
}
function printScorecardBlock(
scorecard: Scorecard,
config: ConfigurationOptions
): void {
core.group('Scorecard', async () => {
if (scorecard) {
for (const dependency of scorecard.dependencies) {
if (
dependency.scorecard?.score &&
dependency.scorecard?.score < config.warn_on_openssf_scorecard_level
) {
core.info(
`${styles.color.red.open}${dependency.change.ecosystem}/${dependency.change.name}: OpenSSF Scorecard Score: ${dependency?.scorecard?.score}${styles.red.close}`
)
}
core.info(
`${dependency.change.ecosystem}/${dependency.change.name}: OpenSSF Scorecard Score: ${dependency?.scorecard?.score}`
)
}
}
})
}
function renderSeverity(
severity: 'critical' | 'high' | 'moderate' | 'low'
): string {
const color = (
{
critical: 'red',
high: 'red',
moderate: 'yellow',
low: 'grey'
} as const
)[severity]
return `${styles.color[color].open}(${severity} severity)${styles.color[color].close}`
}
function renderScannedDependency(change: Change): string {
const changeType: string = change.change_type
if (changeType !== 'added' && changeType !== 'removed') {
throw new Error(`Unexpected change type: ${changeType}`)
}
const color = (
{
added: 'green',
removed: 'red'
} as const
)[changeType]
const icon = (
{
added: '+',
removed: '-'
} as const
)[changeType]
return `${styles.color[color].open}${icon} ${change.name}@${change.version}${styles.color[color].close}`
}
function printScannedDependencies(changes: Changes): void {
core.group('Dependency Changes', async () => {
const dependencies = groupDependenciesByManifest(changes)
for (const manifestName of dependencies.keys()) {
const manifestChanges = dependencies.get(manifestName) || []
core.info(`File: ${styles.bold.open}${manifestName}${styles.bold.close}`)
for (const change of manifestChanges) {
core.info(`${renderScannedDependency(change)}`)
}
}
})
}
async function printDeniedDependencies(
changes: Changes,
config: ConfigurationOptions
): Promise<boolean> {
return core.group('Denied', async () => {
let issueFound = false
for (const denied of config.deny_packages) {
core.info(`Config: ${denied}`)
}
for (const change of changes) {
core.info(`Change: ${change.name}@${change.version} is denied`)
core.info(`Change: ${change.package_url} is denied`)
}
if (changes.length > 0) {
issueFound = true
core.setFailed('Dependency review detected denied packages.')
} else {
core.info('Dependency review did not detect any denied packages')
}
return issueFound
})
}
function getScorecardChanges(changes: Changes): Changes {
const out: Changes = []
for (const change of changes) {
if (change.change_type === 'added') {
out.push(change)
}
}
return out
}
async function createScorecardWarnings(
scorecards: Scorecard,
config: ConfigurationOptions
): Promise<void> {
// Iterate through the list of scorecards, and if the score is less than the threshold, send a warning
for (const dependency of scorecards.dependencies) {
if (
dependency.scorecard?.score &&
dependency.scorecard?.score < config.warn_on_openssf_scorecard_level
) {
core.warning(
`${dependency.change.ecosystem}/${dependency.change.name} has an OpenSSF Scorecard of ${dependency.scorecard?.score}, which is less than this repository's threshold of ${config.warn_on_openssf_scorecard_level}.`,
{
title: 'OpenSSF Scorecard Warning'
}
)
}
}
}
run()