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 { return new Promise(resolve => setTimeout(resolve, ms)) } async function getComparison( baseRef: string, headRef: string, retryOpts?: { retryUntil: number retryDelay: number } ): ReturnType { 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 { 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 { 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 const scorecardChanges = getScorecardChanges(changes) const 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)) summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity) 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 { 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, warnOnly: boolean ): Promise { 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 { 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 { // 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()