437 lines
13 KiB
TypeScript
437 lines
13 KiB
TypeScript
import * as core from '@actions/core'
|
|
import {SummaryTableRow} from '@actions/core/lib/summary'
|
|
import {InvalidLicenseChanges, InvalidLicenseChangeTypes} from './licenses'
|
|
import {Change, Changes, ConfigurationOptions, Scorecard} from './schemas'
|
|
import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils'
|
|
|
|
const icons = {
|
|
check: '✅',
|
|
cross: '❌',
|
|
warning: '⚠️'
|
|
}
|
|
|
|
const MAX_SCANNED_FILES_BYTES = 1048576
|
|
|
|
// generates the DR report summmary and caches it to the Action's core.summary.
|
|
// returns the DR summary string, ready to be posted as a PR comment if the
|
|
// final DR report is too large
|
|
export function addSummaryToSummary(
|
|
vulnerableChanges: Changes,
|
|
invalidLicenseChanges: InvalidLicenseChanges,
|
|
deniedChanges: Changes,
|
|
scorecard: Scorecard,
|
|
config: ConfigurationOptions
|
|
): string {
|
|
const out: string[] = []
|
|
|
|
const scorecardWarnings = countScorecardWarnings(scorecard, config)
|
|
const licenseIssues = countLicenseIssues(invalidLicenseChanges)
|
|
|
|
core.summary.addHeading('Dependency Review', 1)
|
|
out.push('# Dependency Review')
|
|
|
|
addDenyListsDeprecationWarningToSummary()
|
|
|
|
if (
|
|
vulnerableChanges.length === 0 &&
|
|
licenseIssues === 0 &&
|
|
deniedChanges.length === 0 &&
|
|
scorecardWarnings === 0
|
|
) {
|
|
const issueTypes = [
|
|
config.vulnerability_check ? 'vulnerabilities' : '',
|
|
config.license_check ? 'license issues' : '',
|
|
config.show_openssf_scorecard ? 'OpenSSF Scorecard issues' : ''
|
|
]
|
|
|
|
let msg = ''
|
|
if (issueTypes.filter(Boolean).length === 0) {
|
|
msg = `${icons.check} No issues found.`
|
|
} else {
|
|
msg = `${icons.check} No ${issueTypes.filter(Boolean).join(' or ')} found.`
|
|
}
|
|
|
|
core.summary.addRaw(msg)
|
|
out.push(msg)
|
|
return out.join('\n')
|
|
}
|
|
|
|
const foundIssuesHeader = 'The following issues were found:'
|
|
core.summary.addRaw(foundIssuesHeader)
|
|
out.push(foundIssuesHeader)
|
|
|
|
const summaryList: string[] = [
|
|
...(config.vulnerability_check
|
|
? [
|
|
`${checkOrFailIcon(vulnerableChanges.length)} ${
|
|
vulnerableChanges.length
|
|
} vulnerable package(s)`
|
|
]
|
|
: []),
|
|
...(config.license_check
|
|
? [
|
|
`${checkOrFailIcon(invalidLicenseChanges.forbidden.length)} ${
|
|
invalidLicenseChanges.forbidden.length
|
|
} package(s) with incompatible licenses`,
|
|
`${checkOrFailIcon(invalidLicenseChanges.unresolved.length)} ${
|
|
invalidLicenseChanges.unresolved.length
|
|
} package(s) with invalid SPDX license definitions`,
|
|
`${checkOrWarnIcon(invalidLicenseChanges.unlicensed.length)} ${
|
|
invalidLicenseChanges.unlicensed.length
|
|
} package(s) with unknown licenses.`
|
|
]
|
|
: []),
|
|
...(deniedChanges.length > 0
|
|
? [
|
|
`${checkOrWarnIcon(deniedChanges.length)} ${
|
|
deniedChanges.length
|
|
} package(s) denied.`
|
|
]
|
|
: []),
|
|
...(config.show_openssf_scorecard && scorecardWarnings > 0
|
|
? [
|
|
`${checkOrWarnIcon(scorecardWarnings)} ${scorecardWarnings ? scorecardWarnings : 'No'} packages with OpenSSF Scorecard issues.`
|
|
]
|
|
: [])
|
|
]
|
|
|
|
core.summary.addList(summaryList)
|
|
for (const line of summaryList) {
|
|
out.push(`* ${line}`)
|
|
}
|
|
|
|
core.summary.addRaw('See the Details below.')
|
|
out.push(
|
|
`\n[View full job summary](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})`
|
|
)
|
|
|
|
return out.join('\n')
|
|
}
|
|
|
|
function addDenyListsDeprecationWarningToSummary(): void {
|
|
core.summary.addRaw(
|
|
`${icons.warning} The <em>deny-licenses</em> option is deprecated and will be removed in a future version, use <em>allow-licenses</em> instead.<br>`
|
|
)
|
|
}
|
|
|
|
function countScorecardWarnings(
|
|
scorecard: Scorecard,
|
|
config: ConfigurationOptions
|
|
): number {
|
|
return scorecard.dependencies.reduce(
|
|
(total, dependency) =>
|
|
total +
|
|
(dependency.scorecard?.score &&
|
|
dependency.scorecard?.score < config.warn_on_openssf_scorecard_level
|
|
? 1
|
|
: 0),
|
|
0
|
|
)
|
|
}
|
|
|
|
export function addChangeVulnerabilitiesToSummary(
|
|
vulnerableChanges: Changes,
|
|
severity: string
|
|
): void {
|
|
if (vulnerableChanges.length === 0) {
|
|
return
|
|
}
|
|
|
|
const rows: SummaryTableRow[] = []
|
|
|
|
const manifests = getManifestsSet(vulnerableChanges)
|
|
|
|
core.summary.addHeading('Vulnerabilities', 2)
|
|
|
|
for (const manifest of manifests) {
|
|
for (const change of vulnerableChanges.filter(
|
|
pkg => pkg.manifest === manifest
|
|
)) {
|
|
let previous_package = ''
|
|
let previous_version = ''
|
|
for (const vuln of change.vulnerabilities) {
|
|
const sameAsPrevious =
|
|
previous_package === change.name &&
|
|
previous_version === change.version
|
|
|
|
if (!sameAsPrevious) {
|
|
rows.push([
|
|
renderUrl(change.source_repository_url, change.name),
|
|
change.version,
|
|
renderUrl(vuln.advisory_url, vuln.advisory_summary),
|
|
vuln.severity
|
|
])
|
|
} else {
|
|
rows.push([
|
|
{data: '', colspan: '2'},
|
|
renderUrl(vuln.advisory_url, vuln.advisory_summary),
|
|
vuln.severity
|
|
])
|
|
}
|
|
previous_package = change.name
|
|
previous_version = change.version
|
|
}
|
|
}
|
|
core.summary.addHeading(`<em>${manifest}</em>`, 4).addTable([
|
|
[
|
|
{data: 'Name', header: true},
|
|
{data: 'Version', header: true},
|
|
{data: 'Vulnerability', header: true},
|
|
{data: 'Severity', header: true}
|
|
],
|
|
...rows
|
|
])
|
|
}
|
|
|
|
if (severity !== 'low') {
|
|
core.summary.addQuote(
|
|
`Only included vulnerabilities with severity <strong>${severity}</strong> or higher.`
|
|
)
|
|
}
|
|
}
|
|
|
|
export function addLicensesToSummary(
|
|
invalidLicenseChanges: InvalidLicenseChanges,
|
|
config: ConfigurationOptions
|
|
): void {
|
|
if (countLicenseIssues(invalidLicenseChanges) === 0) {
|
|
return
|
|
}
|
|
|
|
core.summary.addHeading('License Issues', 2)
|
|
printLicenseViolations(invalidLicenseChanges)
|
|
|
|
if (config.allow_licenses && config.allow_licenses.length > 0) {
|
|
core.summary.addQuote(
|
|
`<strong>Allowed Licenses</strong>: ${config.allow_licenses.join(', ')}`
|
|
)
|
|
}
|
|
if (config.deny_licenses && config.deny_licenses.length > 0) {
|
|
core.summary.addQuote(
|
|
`<strong>Denied Licenses</strong>: ${config.deny_licenses.join(', ')}`
|
|
)
|
|
}
|
|
if (config.allow_dependencies_licenses) {
|
|
core.summary.addQuote(
|
|
`<strong>Excluded from license check</strong>: ${config.allow_dependencies_licenses.join(
|
|
', '
|
|
)}`
|
|
)
|
|
}
|
|
|
|
core.debug(
|
|
`found ${invalidLicenseChanges.unlicensed.length} unknown licenses`
|
|
)
|
|
|
|
core.debug(
|
|
`${invalidLicenseChanges.unresolved.length} licenses could not be validated`
|
|
)
|
|
}
|
|
|
|
const licenseIssueTypes: InvalidLicenseChangeTypes[] = [
|
|
'forbidden',
|
|
'unresolved',
|
|
'unlicensed'
|
|
]
|
|
|
|
const issueTypeNames: Record<InvalidLicenseChangeTypes, string> = {
|
|
forbidden: 'Incompatible License',
|
|
unresolved: 'Invalid SPDX License',
|
|
unlicensed: 'Unknown License'
|
|
}
|
|
|
|
function printLicenseViolations(changes: InvalidLicenseChanges): void {
|
|
const rowsGroupedByManifest: Record<string, SummaryTableRow[]> = {}
|
|
|
|
for (const issueType of licenseIssueTypes) {
|
|
for (const change of changes[issueType]) {
|
|
if (!rowsGroupedByManifest[change.manifest]) {
|
|
rowsGroupedByManifest[change.manifest] = []
|
|
}
|
|
rowsGroupedByManifest[change.manifest].push([
|
|
renderUrl(change.source_repository_url, change.name),
|
|
change.version,
|
|
formatLicense(change.license),
|
|
issueTypeNames[issueType]
|
|
])
|
|
}
|
|
}
|
|
|
|
for (const [manifest, rows] of Object.entries(rowsGroupedByManifest)) {
|
|
core.summary.addHeading(`<em>${manifest}</em>`, 4)
|
|
core.summary.addTable([
|
|
['Package', 'Version', 'License', 'Issue Type'],
|
|
...rows
|
|
])
|
|
}
|
|
}
|
|
|
|
function formatLicense(license: string | null): string {
|
|
if (license === null || license === 'NOASSERTION') {
|
|
return 'Null'
|
|
}
|
|
return license
|
|
}
|
|
|
|
export function addScannedFiles(changes: Changes): void {
|
|
const manifests = Array.from(
|
|
groupDependenciesByManifest(changes).keys()
|
|
).sort()
|
|
|
|
let sf_size = 0
|
|
let trunc_at = -1
|
|
|
|
for (const [index, entry] of manifests.entries()) {
|
|
if (sf_size + entry.length >= MAX_SCANNED_FILES_BYTES) {
|
|
trunc_at = index
|
|
break
|
|
}
|
|
sf_size += entry.length
|
|
}
|
|
|
|
if (trunc_at >= 0) {
|
|
// truncate the manifests list if it will overflow the summary output
|
|
manifests.slice(0, trunc_at)
|
|
// if there's room between cutoff size and list size, add a warning
|
|
const size_diff = MAX_SCANNED_FILES_BYTES - sf_size
|
|
if (size_diff < 12) {
|
|
manifests.push('(truncated)')
|
|
}
|
|
}
|
|
|
|
const summary = core.summary.addHeading('Scanned Files', 2)
|
|
if (manifests.length === 0) {
|
|
summary.addRaw('None')
|
|
} else {
|
|
summary.addList(manifests)
|
|
}
|
|
}
|
|
|
|
function snapshotWarningRecommendation(
|
|
config: ConfigurationOptions,
|
|
warnings: string
|
|
): string {
|
|
const no_pr_snaps = warnings.includes(
|
|
'No snapshots were found for the head SHA'
|
|
)
|
|
const retries_disabled = !config.retry_on_snapshot_warnings
|
|
if (no_pr_snaps && retries_disabled) {
|
|
return 'Ensure that dependencies are being submitted on PR branches and consider enabling <em>retry-on-snapshot-warnings</em>.'
|
|
} else if (no_pr_snaps) {
|
|
return 'Ensure that dependencies are being submitted on PR branches. Re-running this action after a short time may resolve the issue.'
|
|
} else if (retries_disabled) {
|
|
return 'Consider enabling <em>retry-on-snapshot-warnings</em>.'
|
|
}
|
|
return 'Re-running this action after a short time may resolve the issue.'
|
|
}
|
|
|
|
export function addScorecardToSummary(
|
|
scorecard: Scorecard,
|
|
config: ConfigurationOptions
|
|
): void {
|
|
if (scorecard.dependencies.length === 0) {
|
|
return
|
|
}
|
|
core.summary.addHeading('OpenSSF Scorecard', 2)
|
|
if (scorecard.dependencies.length > 10) {
|
|
core.summary.addRaw(`<details><summary>Scorecard details</summary>`, true)
|
|
}
|
|
core.summary.addRaw(
|
|
`<table><tr><th>Package</th><th>Version</th><th>Score</th><th>Details</th></tr>`,
|
|
true
|
|
)
|
|
for (const dependency of scorecard.dependencies) {
|
|
core.debug('Adding scorecard to summary')
|
|
core.debug(`Overall score ${dependency.scorecard?.score}`)
|
|
|
|
// Set the icon based on the overall score value
|
|
let overallIcon = ''
|
|
if (dependency.scorecard?.score) {
|
|
overallIcon =
|
|
dependency.scorecard?.score < config.warn_on_openssf_scorecard_level
|
|
? ':warning:'
|
|
: ':green_circle:'
|
|
}
|
|
|
|
//Add a row for the dependency
|
|
core.summary.addRaw(
|
|
`<tr><td>${dependency.change.source_repository_url ? `<a href="${dependency.change.source_repository_url}">` : ''} ${dependency.change.ecosystem}/${dependency.change.name} ${dependency.change.source_repository_url ? `</a>` : ''}</td><td>${dependency.change.version}</td>
|
|
<td>${overallIcon} ${dependency.scorecard?.score === undefined || dependency.scorecard?.score === null ? 'Unknown' : dependency.scorecard?.score}</td>`,
|
|
false
|
|
)
|
|
|
|
//Add details table in the last column
|
|
if (dependency.scorecard?.checks !== undefined) {
|
|
let detailsTable =
|
|
'<table><tr><th>Check</th><th>Score</th><th>Reason</th></tr>'
|
|
for (const check of dependency.scorecard?.checks || []) {
|
|
const icon =
|
|
parseFloat(check.score) < config.warn_on_openssf_scorecard_level
|
|
? ':warning:'
|
|
: ':green_circle:'
|
|
|
|
detailsTable += `<tr><td>${check.name}</td><td>${icon} ${check.score}</td><td>${check.reason}</td></tr>`
|
|
}
|
|
detailsTable += `</table>`
|
|
core.summary.addRaw(
|
|
`<td><details><summary>Details</summary>${detailsTable}</details></td></tr>`,
|
|
true
|
|
)
|
|
} else {
|
|
core.summary.addRaw('<td>Unknown</td></tr>', true)
|
|
}
|
|
}
|
|
core.summary.addRaw(`</table>`)
|
|
if (scorecard.dependencies.length > 10) {
|
|
core.summary.addRaw(`</details>`)
|
|
}
|
|
}
|
|
|
|
export function addSnapshotWarnings(
|
|
config: ConfigurationOptions,
|
|
warnings: string
|
|
): void {
|
|
core.summary.addHeading('Snapshot Warnings', 2)
|
|
core.summary.addQuote(`${icons.warning}: ${warnings}`)
|
|
const recommendation = snapshotWarningRecommendation(config, warnings)
|
|
const docsLink =
|
|
'See <a href="https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#best-practices-for-using-the-dependency-review-api-and-the-dependency-submission-api-together">the documentation</a> for more information and troubleshooting advice.'
|
|
core.summary.addRaw(`${recommendation} ${docsLink}`)
|
|
}
|
|
|
|
function countLicenseIssues(
|
|
invalidLicenseChanges: InvalidLicenseChanges
|
|
): number {
|
|
return Object.values(invalidLicenseChanges).reduce(
|
|
(acc, val) => acc + val.length,
|
|
0
|
|
)
|
|
}
|
|
|
|
export function addDeniedToSummary(deniedChanges: Change[]): void {
|
|
if (deniedChanges.length === 0) {
|
|
return
|
|
}
|
|
|
|
core.summary.addHeading('Denied dependencies', 2)
|
|
for (const change of deniedChanges) {
|
|
core.summary.addHeading(`<em>Denied dependencies</em>`, 4)
|
|
core.summary.addTable([
|
|
['Package', 'Version', 'License'],
|
|
[
|
|
renderUrl(change.source_repository_url, change.name),
|
|
change.version,
|
|
change.license || ''
|
|
]
|
|
])
|
|
}
|
|
}
|
|
|
|
function checkOrFailIcon(count: number): string {
|
|
return count === 0 ? icons.check : icons.cross
|
|
}
|
|
|
|
function checkOrWarnIcon(count: number): string {
|
|
return count === 0 ? icons.check : icons.warning
|
|
}
|