Merge pull request #416 from davelosert/adjust_summary_format

Adjust summary format
This commit is contained in:
Federico Builes
2023-03-06 09:10:58 +01:00
committed by GitHub
17 changed files with 747 additions and 163 deletions

2
.gitignore vendored
View File

@@ -100,3 +100,5 @@ Thumbs.db
# Ignore built ts files
__tests__/runner/*
lib/**/*
tmp

View File

@@ -5,13 +5,13 @@ import * as Utils from '../src/utils'
// GitHub Action inputs come in the form of environment variables
// with an INPUT prefix (e.g. INPUT_FAIL-ON-SEVERITY)
function setInput(input: string, value: string) {
function setInput(input: string, value: string): void {
process.env[`INPUT_${input.toUpperCase()}`] = value
}
// We want a clean ENV before each test. We use `delete`
// since we want `undefined` values and not empty strings.
function clearInputs() {
function clearInputs(): void {
const allowedOptions = [
'FAIL-ON-SEVERITY',
'FAIL-ON-SCOPES',
@@ -26,6 +26,7 @@ function clearInputs() {
'COMMENT-SUMMARY-IN-PR'
]
// eslint-disable-next-line github/array-foreach
allowedOptions.forEach(option => {
delete process.env[`INPUT_${option.toUpperCase()}`]
})
@@ -238,7 +239,7 @@ test('it supports comma-separated lists', async () => {
'config-file',
'./__tests__/fixtures/inline-license-config-sample.yml'
)
let config = await readConfig()
const config = await readConfig()
expect(config.allow_licenses).toEqual(['MIT', 'GPL-2.0-only'])
})

View File

@@ -1,12 +1,12 @@
import {expect, test} from '@jest/globals'
import {Change, Changes} from '../src/schemas'
import {Change} from '../src/schemas'
import {
filterChangesBySeverity,
filterChangesByScopes,
filterAllowedAdvisories
} from '../src/filter'
let npmChange: Change = {
const npmChange: Change = {
manifest: 'package.json',
change_type: 'added',
ecosystem: 'npm',
@@ -26,7 +26,7 @@ let npmChange: Change = {
]
}
let rubyChange: Change = {
const rubyChange: Change = {
change_type: 'added',
manifest: 'Gemfile.lock',
ecosystem: 'rubygems',
@@ -52,7 +52,7 @@ let rubyChange: Change = {
]
}
let noVulnNpmChange: Change = {
const noVulnNpmChange: Change = {
manifest: 'package.json',
change_type: 'added',
ecosystem: 'npm',
@@ -92,7 +92,7 @@ test('it properly filters changes by scope', async () => {
test('it properly handles undefined advisory IDs', async () => {
const changes = [npmChange, rubyChange, noVulnNpmChange]
let result = filterAllowedAdvisories(undefined, changes)
const result = filterAllowedAdvisories(undefined, changes)
expect(result).toEqual([npmChange, rubyChange, noVulnNpmChange])
})

View File

@@ -0,0 +1,36 @@
import {Change} from '../../src/schemas'
import {createTestVulnerability} from './create-test-vulnerability'
const defaultChange: Change = {
change_type: 'added',
manifest: 'package.json',
ecosystem: 'npm',
name: 'lodash',
version: '4.17.20',
package_url: 'pkg:npm/lodash@4.17.20',
license: 'MIT',
source_repository_url: 'https://github.com/lodash/lodash',
scope: 'runtime',
vulnerabilities: [
createTestVulnerability({
severity: 'high',
advisory_ghsa_id: 'GHSA-35jh-r3h4-6jhm',
advisory_summary: 'Command Injection in lodash',
advisory_url: 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm'
}),
createTestVulnerability({
severity: 'moderate',
advisory_ghsa_id: 'GHSA-29mw-wpgm-hmr9',
advisory_summary:
'Regular Expression Denial of Service (ReDoS) in lodash',
advisory_url: 'https://github.com/advisories/GHSA-29mw-wpgm-hmr9'
})
]
}
const createTestChange = (overwrites: Partial<Change> = {}): Change => ({
...defaultChange,
...overwrites
})
export {createTestChange}

View File

@@ -0,0 +1,19 @@
import {Change} from '../../src/schemas'
type Vulnerability = Change['vulnerabilities'][0]
const defaultTestVulnerability: Vulnerability = {
severity: 'high',
advisory_ghsa_id: 'GHSA-35jh-r3h4-6jhm',
advisory_summary: 'Command Injection in lodash',
advisory_url: 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm'
}
const createTestVulnerability = (
overwrites: Partial<Vulnerability> = {}
): Vulnerability => ({
...defaultTestVulnerability,
...overwrites
})
export {createTestVulnerability}

View File

@@ -3,7 +3,7 @@ import {Change, Changes} from '../src/schemas'
let getInvalidLicenseChanges: Function
let npmChange: Change = {
const npmChange: Change = {
manifest: 'package.json',
change_type: 'added',
ecosystem: 'npm',
@@ -23,7 +23,7 @@ let npmChange: Change = {
]
}
let rubyChange: Change = {
const rubyChange: Change = {
change_type: 'added',
manifest: 'Gemfile.lock',
ecosystem: 'rubygems',
@@ -63,6 +63,7 @@ const mockOctokit = {
jest.mock('octokit', () => {
return {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
Octokit: class {
constructor() {
return mockOctokit
@@ -78,6 +79,7 @@ beforeEach(async () => {
// true for BSD, false for all others
return jest.fn((license: string, _: string): boolean => license === 'BSD')
})
// eslint-disable-next-line @typescript-eslint/no-require-imports
;({getInvalidLicenseChanges} = require('../src/licenses'))
})
@@ -140,6 +142,7 @@ test('it adds all licenses to unresolved if it is unable to determine the validi
throw new Error('Some Error')
})
})
// eslint-disable-next-line @typescript-eslint/no-require-imports
;({getInvalidLicenseChanges} = require('../src/licenses'))
const changes: Changes = [npmChange, rubyChange]
const invalidLicenses = await getInvalidLicenseChanges(changes, {

305
__tests__/summary.test.ts Normal file
View File

@@ -0,0 +1,305 @@
import {expect, jest, test} from '@jest/globals'
import {Changes, ConfigurationOptions} from '../src/schemas'
import * as summary from '../src/summary'
import * as core from '@actions/core'
import {createTestChange} from './fixtures/create-test-change'
import {createTestVulnerability} from './fixtures/create-test-vulnerability'
afterEach(() => {
jest.clearAllMocks()
core.summary.emptyBuffer()
})
const emptyChanges: Changes = []
const emptyInvalidLicenseChanges = {
forbidden: [],
unresolved: [],
unlicensed: []
}
const defaultConfig: ConfigurationOptions = {
vulnerability_check: true,
license_check: true,
fail_on_severity: 'high',
fail_on_scopes: ['runtime'],
allow_ghsas: [],
allow_licenses: [],
deny_licenses: [],
comment_summary_in_pr: true
}
test('prints headline as h1', () => {
summary.addSummaryToSummary(
emptyChanges,
emptyInvalidLicenseChanges,
defaultConfig
)
const text = core.summary.stringify()
expect(text).toContain('<h1>Dependency Review</h1>')
})
test('only includes "No vulnerabilities or license issues found"-message if both are configured and nothing was found', () => {
summary.addSummaryToSummary(
emptyChanges,
emptyInvalidLicenseChanges,
defaultConfig
)
const text = core.summary.stringify()
expect(text).toContain('✅ No vulnerabilities or license issues found.')
})
test('only includes "No vulnerabilities found"-message if "license_check" is set to false and nothing was found', () => {
const config = {...defaultConfig, license_check: false}
summary.addSummaryToSummary(emptyChanges, emptyInvalidLicenseChanges, config)
const text = core.summary.stringify()
expect(text).toContain('✅ No vulnerabilities found.')
})
test('only includes "No license issues found"-message if "vulnerability_check" is set to false and nothing was found', () => {
const config = {...defaultConfig, vulnerability_check: false}
summary.addSummaryToSummary(emptyChanges, emptyInvalidLicenseChanges, config)
const text = core.summary.stringify()
expect(text).toContain('✅ No license issues found.')
})
test('does not include status section if nothing was found', () => {
summary.addSummaryToSummary(
emptyChanges,
emptyInvalidLicenseChanges,
defaultConfig
)
const text = core.summary.stringify()
expect(text).not.toContain('The following issues were found:')
})
test('includes count and status icons for all findings', () => {
const vulnerabilities = [
createTestChange({name: 'lodash'}),
createTestChange({name: 'underscore', package_url: 'test-url'})
]
const licenseIssues = {
forbidden: [createTestChange()],
unresolved: [createTestChange(), createTestChange()],
unlicensed: [createTestChange(), createTestChange(), createTestChange()]
}
summary.addSummaryToSummary(vulnerabilities, licenseIssues, defaultConfig)
const text = core.summary.stringify()
expect(text).toContain('❌ 2 vulnerable package(s)')
expect(text).toContain(
'❌ 2 package(s) with invalid SPDX license definitions'
)
expect(text).toContain('❌ 1 package(s) with incompatible licenses')
expect(text).toContain('⚠️ 3 package(s) with unknown licenses')
})
test('uses checkmarks for license issues if only vulnerabilities were found', () => {
const vulnerabilities = [createTestChange()]
summary.addSummaryToSummary(
vulnerabilities,
emptyInvalidLicenseChanges,
defaultConfig
)
const text = core.summary.stringify()
expect(text).toContain('❌ 1 vulnerable package(s)')
expect(text).toContain(
'✅ 0 package(s) with invalid SPDX license definitions'
)
expect(text).toContain('✅ 0 package(s) with incompatible licenses')
expect(text).toContain('✅ 0 package(s) with unknown licenses')
})
test('uses checkmarks for vulnerabilities if only license issues were found', () => {
const licenseIssues = {
forbidden: [createTestChange()],
unresolved: [],
unlicensed: []
}
summary.addSummaryToSummary(emptyChanges, licenseIssues, defaultConfig)
const text = core.summary.stringify()
expect(text).toContain('✅ 0 vulnerable package(s)')
expect(text).toContain(
'✅ 0 package(s) with invalid SPDX license definitions'
)
expect(text).toContain('❌ 1 package(s) with incompatible licenses')
expect(text).toContain('✅ 0 package(s) with unknown licenses')
})
test('addChangeVulnerabilitiesToSummary() - only includes section if any vulnerabilites found', () => {
summary.addChangeVulnerabilitiesToSummary(emptyChanges, 'low')
const text = core.summary.stringify()
expect(text).toEqual('')
})
test('addChangeVulnerabilitiesToSummary() - includes all vulnerabilities', () => {
const changes = [
createTestChange({name: 'lodash'}),
createTestChange({name: 'underscore', package_url: 'test-url'})
]
summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text).toContain('<h2>Vulnerabilities</h2>')
expect(text).toContain('lodash')
expect(text).toContain('underscore')
})
test('addChangeVulnerabilitiesToSummary() - includes advisory url if available', () => {
const changes = [
createTestChange({
name: 'underscore',
vulnerabilities: [
createTestVulnerability({
advisory_summary: 'test-summary',
advisory_url: 'test-url'
})
]
})
]
summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text).toContain('lodash')
expect(text).toContain('<a href="test-url">test-summary</a>')
})
test('addChangeVulnerabilitiesToSummary() - groups vulnerabilities of a single package', () => {
const changes = [
createTestChange({
name: 'package-with-multiple-vulnerabilities',
vulnerabilities: [
createTestVulnerability({advisory_summary: 'test-summary-1'}),
createTestVulnerability({advisory_summary: 'test-summary-2'})
]
})
]
summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text.match('package-with-multiple-vulnerabilities')).toHaveLength(1)
expect(text).toContain('test-summary-1')
expect(text).toContain('test-summary-2')
})
test('addChangeVulnerabilitiesToSummary() - prints severity statement if above low', () => {
const changes = [createTestChange()]
summary.addChangeVulnerabilitiesToSummary(changes, 'medium')
const text = core.summary.stringify()
expect(text).toContain(
'Only included vulnerabilities with severity <strong>medium</strong> or higher.'
)
})
test('addChangeVulnerabilitiesToSummary() - does not print severity statment if it is set to "low"', () => {
const changes = [createTestChange()]
summary.addChangeVulnerabilitiesToSummary(changes, 'low')
const text = core.summary.stringify()
expect(text).not.toContain('Only included vulnerabilities')
})
test('addLicensesToSummary() - does not include entire section if no license issues found', () => {
summary.addLicensesToSummary(emptyInvalidLicenseChanges, defaultConfig)
const text = core.summary.stringify()
expect(text).toEqual('')
})
test('addLicensesToSummary() - includes all license issues in table', () => {
const licenseIssues = {
forbidden: [createTestChange()],
unresolved: [createTestChange(), createTestChange()],
unlicensed: [createTestChange(), createTestChange(), createTestChange()]
}
summary.addLicensesToSummary(licenseIssues, defaultConfig)
const text = core.summary.stringify()
expect(text).toContain('<h2>License Issues</h2>')
expect(text).toContain('<td>Incompatible License</td>')
expect(text).toContain('<td>Invalid SPDX License</td>')
expect(text).toContain('<td>Unknown License</td>')
})
test('addLicenseToSummary() - adds one table per manifest', () => {
const licenseIssues = {
forbidden: [
createTestChange({manifest: 'package.json'}),
createTestChange({manifest: '.github/workflows/test.yml'})
],
unresolved: [],
unlicensed: []
}
summary.addLicensesToSummary(licenseIssues, defaultConfig)
const text = core.summary.stringify()
expect(text).toContain('<h4><em>package.json</em></h4>')
expect(text).toContain('<h4><em>.github/workflows/test.yml</em></h4>')
})
test('addLicensesToSummary() - does not include specific license type sub-section if nothing is found', () => {
const licenseIssues = {
forbidden: [],
unlicensed: [],
unresolved: [createTestChange()]
}
summary.addLicensesToSummary(licenseIssues, defaultConfig)
const text = core.summary.stringify()
expect(text).not.toContain('<td>Incompatible License</td>')
expect(text).not.toContain('<td>Unknown License</td>')
expect(text).toContain('<td>Invalid SPDX License</td>')
})
test('addLicensesToSummary() - includes list of configured allowed licenses', () => {
const licenseIssues = {
forbidden: [createTestChange()],
unresolved: [],
unlicensed: []
}
const config: ConfigurationOptions = {
...defaultConfig,
allow_licenses: ['MIT', 'Apache-2.0']
}
summary.addLicensesToSummary(licenseIssues, config)
const text = core.summary.stringify()
expect(text).toContain('<strong>Allowed Licenses</strong>: MIT, Apache-2.0')
})
test('addLicensesToSummary() - includes configured denied license', () => {
const licenseIssues = {
forbidden: [createTestChange()],
unresolved: [],
unlicensed: []
}
const config: ConfigurationOptions = {
...defaultConfig,
deny_licenses: ['MIT']
}
summary.addLicensesToSummary(licenseIssues, config)
const text = core.summary.stringify()
expect(text).toContain('<strong>Denied Licenses</strong>: MIT')
})

171
dist/index.js generated vendored
View File

@@ -259,18 +259,6 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.getInvalidLicenseChanges = void 0;
const spdx_satisfies_1 = __importDefault(__nccwpck_require__(4424));
const utils_1 = __nccwpck_require__(918);
/**
* Loops through a list of changes, filtering and returning the
* ones that don't conform to the licenses allow/deny lists.
*
* Keep in mind that we don't let users specify both an allow and a deny
* list in their config files, so this code works under the assumption that
* one of the two list parameters will be empty. If both lists are provided,
* we will ignore the deny list.
* @param {Change[]} changes The list of changes to filter.
* @param { { allow?: string[], deny?: string[]}} licenses An object with `allow`/`deny` keys, each containing a list of licenses.
* @returns {Promise<{Object.<string, Array.<Change>>}} A promise to a Record Object. The keys are strings, unlicensed, unresolved and forbidden. The values are a list of changes
*/
function getInvalidLicenseChanges(changes, licenses) {
return __awaiter(this, void 0, void 0, function* () {
const { allow, deny } = licenses;
@@ -470,20 +458,24 @@ function run() {
baseRef: refs.base,
headRef: refs.head
});
if (!changes) {
core.info('No Dependency Changes found. Skipping Dependency Review.');
return;
}
const minSeverity = config.fail_on_severity;
const scopedChanges = (0, filter_1.filterChangesByScopes)(config.fail_on_scopes, changes);
const filteredChanges = (0, filter_1.filterAllowedAdvisories)(config.allow_ghsas, scopedChanges);
const addedChanges = (0, filter_1.filterChangesBySeverity)(minSeverity, filteredChanges).filter(change => change.change_type === 'added' &&
const vulnerableChanges = (0, filter_1.filterChangesBySeverity)(minSeverity, filteredChanges).filter(change => change.change_type === 'added' &&
change.vulnerabilities !== undefined &&
change.vulnerabilities.length > 0);
const invalidLicenseChanges = yield (0, licenses_1.getInvalidLicenseChanges)(filteredChanges, {
allow: config.allow_licenses,
deny: config.deny_licenses
});
summary.addSummaryToSummary(config.vulnerability_check ? addedChanges : null, config.license_check ? invalidLicenseChanges : null);
summary.addSummaryToSummary(vulnerableChanges, invalidLicenseChanges, config);
if (config.vulnerability_check) {
summary.addChangeVulnerabilitiesToSummary(addedChanges, minSeverity);
printVulnerabilitiesBlock(addedChanges, minSeverity);
summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity);
printVulnerabilitiesBlock(vulnerableChanges, minSeverity);
}
if (config.license_check) {
summary.addLicensesToSummary(invalidLicenseChanges, config);
@@ -740,36 +732,54 @@ Object.defineProperty(exports, "__esModule", ({ value: true }));
exports.addScannedDependencies = exports.addLicensesToSummary = exports.addChangeVulnerabilitiesToSummary = exports.addSummaryToSummary = void 0;
const core = __importStar(__nccwpck_require__(2186));
const utils_1 = __nccwpck_require__(918);
function addSummaryToSummary(addedPackages, invalidLicenseChanges) {
core.summary
.addHeading('Dependency Review')
.addRaw('We found:')
.addList([
...(addedPackages
? [`${addedPackages.length} vulnerable package(s)`]
: []),
...(invalidLicenseChanges
? [
`${invalidLicenseChanges.unresolved.length} package(s) with invalid SPDX license definitions`,
`${invalidLicenseChanges.forbidden.length} package(s) with incompatible licenses`,
`${invalidLicenseChanges.unlicensed.length} package(s) with unknown licenses.`
]
: [])
]);
}
exports.addSummaryToSummary = addSummaryToSummary;
function addChangeVulnerabilitiesToSummary(addedPackages, severity) {
const rows = [];
const manifests = (0, utils_1.getManifestsSet)(addedPackages);
core.summary
.addHeading('Vulnerabilities')
.addQuote(`Vulnerabilities were filtered by minimum severity <strong>${severity}</strong>.`);
if (addedPackages.length === 0) {
core.summary.addQuote('No vulnerabilities found in added packages.');
const icons = {
check: '✅',
cross: '❌',
warning: '⚠️'
};
function addSummaryToSummary(vulnerableChanges, invalidLicenseChanges, config) {
core.summary.addHeading('Dependency Review', 1);
if (vulnerableChanges.length === 0 &&
countLicenseIssues(invalidLicenseChanges) === 0) {
if (!config.license_check) {
core.summary.addRaw(`${icons.check} No vulnerabilities found.`);
}
else if (!config.vulnerability_check) {
core.summary.addRaw(`${icons.check} No license issues found.`);
}
else {
core.summary.addRaw(`${icons.check} No vulnerabilities or license issues found.`);
}
return;
}
core.summary
.addRaw('The following issues were found:')
.addList([
...(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.`
]
: [])
])
.addRaw('See the Details below.');
}
exports.addSummaryToSummary = addSummaryToSummary;
function addChangeVulnerabilitiesToSummary(vulnerableChanges, severity) {
if (vulnerableChanges.length === 0) {
return;
}
const rows = [];
const manifests = (0, utils_1.getManifestsSet)(vulnerableChanges);
core.summary.addHeading('Vulnerabilities', 2);
for (const manifest of manifests) {
for (const change of addedPackages.filter(pkg => pkg.manifest === manifest)) {
for (const change of vulnerableChanges.filter(pkg => pkg.manifest === manifest)) {
let previous_package = '';
let previous_version = '';
for (const vuln of change.vulnerabilities) {
@@ -794,7 +804,7 @@ function addChangeVulnerabilitiesToSummary(addedPackages, severity) {
previous_version = change.version;
}
}
core.summary.addHeading(`<em>${manifest}</em>`, 3).addTable([
core.summary.addHeading(`<em>${manifest}</em>`, 4).addTable([
[
{ data: 'Name', header: true },
{ data: 'Version', header: true },
@@ -804,46 +814,58 @@ function addChangeVulnerabilitiesToSummary(addedPackages, severity) {
...rows
]);
}
if (severity !== 'low') {
core.summary.addQuote(`Only included vulnerabilities with severity <strong>${severity}</strong> or higher.`);
}
}
exports.addChangeVulnerabilitiesToSummary = addChangeVulnerabilitiesToSummary;
function addLicensesToSummary(invalidLicenseChanges, config) {
core.summary.addHeading('Licenses');
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 (Object.values(invalidLicenseChanges).every(item => item.length === 0)) {
core.summary.addQuote('No license violations detected.');
return;
}
core.debug(`found ${invalidLicenseChanges.unlicensed.length} unknown licenses`);
core.debug(`${invalidLicenseChanges.unresolved.length} licenses could not be validated`);
printLicenseViolation('Incompatible Licenses', invalidLicenseChanges.forbidden);
printLicenseViolation('Unknown Licenses', invalidLicenseChanges.unlicensed);
printLicenseViolation('Invalid SPDX License Definitions', invalidLicenseChanges.unresolved);
}
exports.addLicensesToSummary = addLicensesToSummary;
function printLicenseViolation(heading, changes) {
core.summary.addHeading(heading, 5).addSeparator();
if (changes.length > 0) {
const rows = [];
const manifests = (0, utils_1.getManifestsSet)(changes);
for (const manifest of manifests) {
core.summary.addHeading(`<em>${manifest}</em>`, 4);
for (const change of changes.filter(pkg => pkg.manifest === manifest)) {
rows.push([
(0, utils_1.renderUrl)(change.source_repository_url, change.name),
change.version,
formatLicense(change.license)
]);
const licenseIssueTypes = [
'forbidden',
'unresolved',
'unlicensed'
];
const issueTypeNames = {
forbidden: 'Incompatible License',
unresolved: 'Invalid SPDX License',
unlicensed: 'Unknown License'
};
function printLicenseViolations(changes) {
const rowsGroupedByManifest = {};
for (const issueType of licenseIssueTypes) {
for (const change of changes[issueType]) {
if (!rowsGroupedByManifest[change.manifest]) {
rowsGroupedByManifest[change.manifest] = [];
}
core.summary.addTable([['Package', 'Version', 'License'], ...rows]);
rowsGroupedByManifest[change.manifest].push([
(0, utils_1.renderUrl)(change.source_repository_url, change.name),
change.version,
formatLicense(change.license),
issueTypeNames[issueType]
]);
}
}
else {
core.summary.addQuote(`No ${heading.toLowerCase()} detected.`);
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) {
@@ -855,9 +877,7 @@ function formatLicense(license) {
function addScannedDependencies(changes) {
const dependencies = (0, utils_1.groupDependenciesByManifest)(changes);
const manifests = dependencies.keys();
const summary = core.summary
.addHeading('Scanned Dependencies')
.addHeading(`We scanned ${dependencies.size} manifest files:`, 5);
const summary = core.summary.addHeading('Scanned Manifest Files', 2);
for (const manifest of manifests) {
const deps = dependencies.get(manifest);
if (deps) {
@@ -867,6 +887,15 @@ function addScannedDependencies(changes) {
}
}
exports.addScannedDependencies = addScannedDependencies;
function countLicenseIssues(invalidLicenseChanges) {
return Object.values(invalidLicenseChanges).reduce((acc, val) => acc + val.length, 0);
}
function checkOrFailIcon(count) {
return count === 0 ? icons.check : icons.cross;
}
function checkOrWarnIcon(count) {
return count === 0 ? icons.check : icons.warning;
}
/***/ }),

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@@ -5,7 +5,7 @@
"description": "A GitHub Action for Dependency Review",
"main": "lib/main.js",
"scripts": {
"build": "tsc",
"build": "tsc -p tsconfig.build.json",
"format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'",
"lint": "eslint src/**/*.ts",

114
scripts/create_summary.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* This scripts creates example markdown files for the summary in the ./tmp folder.
* You can use it to preview changes to the summary.
*
* You can execute it like this:
* npx ts-node scripts/create_summary.ts
*/
import {Changes, ConfigurationOptions} from '../src/schemas'
import {createTestChange} from '../__tests__/fixtures/create-test-change'
import {InvalidLicenseChanges} from '../src/licenses'
import * as fs from 'fs'
import * as core from '@actions/core'
import * as summary from '../src/summary'
import * as path from 'path'
const defaultConfig: ConfigurationOptions = {
vulnerability_check: true,
license_check: true,
fail_on_severity: 'high',
fail_on_scopes: ['runtime'],
allow_ghsas: [],
allow_licenses: ['MIT'],
deny_licenses: [],
comment_summary_in_pr: true
}
const tmpDir = path.resolve(__dirname, '../tmp')
const createExampleSummaries = async (): Promise<void> => {
await fs.promises.mkdir(tmpDir, {recursive: true})
await createNonIssueSummary()
await createFullSummary()
}
const createNonIssueSummary = async (): Promise<void> => {
await createSummary(
[],
{forbidden: [], unresolved: [], unlicensed: []},
defaultConfig,
'non-issue-summary.md'
)
}
const createFullSummary = async (): Promise<void> => {
const changes = [createTestChange()]
const licenses: InvalidLicenseChanges = {
forbidden: [
createTestChange({
name: 'underscore',
version: '1.12.0',
license: 'Apache 2.0'
})
],
unresolved: [
createTestChange({
name: 'octoinvader',
license: 'Non SPDX License'
}),
createTestChange({
name: 'owner/action-1',
license: 'XYZ-License',
version: 'v1.2.2',
manifest: '.github/workflows/action.yml'
})
],
unlicensed: [
createTestChange({
name: 'my-other-dependency',
license: null
}),
createTestChange({
name: 'owner/action-2',
version: 'main',
license: null,
manifest: '.github/workflows/action.yml'
})
]
}
await createSummary(changes, licenses, defaultConfig, 'full-summary.md')
}
async function createSummary(
vulnerabilities: Changes,
licenseIssues: InvalidLicenseChanges,
config: ConfigurationOptions,
fileName: string
): Promise<void> {
summary.addSummaryToSummary(vulnerabilities, licenseIssues, config)
summary.addChangeVulnerabilitiesToSummary(
vulnerabilities,
config.fail_on_severity
)
summary.addLicensesToSummary(licenseIssues, defaultConfig)
const allChanges = [
...vulnerabilities,
...licenseIssues.forbidden,
...licenseIssues.unresolved,
...licenseIssues.unlicensed
]
summary.addScannedDependencies(allChanges)
const text = core.summary.stringify()
await fs.promises.writeFile(path.resolve(tmpDir, fileName), text, {
flag: 'w'
})
core.summary.emptyBuffer()
}
createExampleSummaries()

View File

@@ -14,19 +14,24 @@ import {isSPDXValid, octokitClient} from './utils'
* @param { { allow?: string[], deny?: string[]}} licenses An object with `allow`/`deny` keys, each containing a list of licenses.
* @returns {Promise<{Object.<string, Array.<Change>>}} A promise to a Record Object. The keys are strings, unlicensed, unresolved and forbidden. The values are a list of changes
*/
export type InvalidLicenseChangeTypes =
| 'unlicensed'
| 'unresolved'
| 'forbidden'
export type InvalidLicenseChanges = Record<InvalidLicenseChangeTypes, Changes>
export async function getInvalidLicenseChanges(
changes: Change[],
licenses: {
allow?: string[]
deny?: string[]
}
): Promise<Record<string, Changes>> {
): Promise<InvalidLicenseChanges> {
const {allow, deny} = licenses
const groupedChanges = await groupChanges(changes)
const licensedChanges: Changes = groupedChanges.licensed
const invalidLicenseChanges: Record<string, Changes> = {
const invalidLicenseChanges: InvalidLicenseChanges = {
unlicensed: groupedChanges.unlicensed,
unresolved: [],
forbidden: []

View File

@@ -29,6 +29,11 @@ async function run(): Promise<void> {
headRef: refs.head
})
if (!changes) {
core.info('No Dependency Changes found. Skipping Dependency Review.')
return
}
const minSeverity = config.fail_on_severity
const scopedChanges = filterChangesByScopes(config.fail_on_scopes, changes)
const filteredChanges = filterAllowedAdvisories(
@@ -36,7 +41,7 @@ async function run(): Promise<void> {
scopedChanges
)
const addedChanges = filterChangesBySeverity(
const vulnerableChanges = filterChangesBySeverity(
minSeverity,
filteredChanges
).filter(
@@ -55,13 +60,14 @@ async function run(): Promise<void> {
)
summary.addSummaryToSummary(
config.vulnerability_check ? addedChanges : null,
config.license_check ? invalidLicenseChanges : null
vulnerableChanges,
invalidLicenseChanges,
config
)
if (config.vulnerability_check) {
summary.addChangeVulnerabilitiesToSummary(addedChanges, minSeverity)
printVulnerabilitiesBlock(addedChanges, minSeverity)
summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity)
printVulnerabilitiesBlock(vulnerableChanges, minSeverity)
}
if (config.license_check) {
summary.addLicensesToSummary(invalidLicenseChanges, config)

View File

@@ -1,50 +1,82 @@
import * as core from '@actions/core'
import {ConfigurationOptions, Changes} from './schemas'
import {SummaryTableRow} from '@actions/core/lib/summary'
import {InvalidLicenseChanges, InvalidLicenseChangeTypes} from './licenses'
import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils'
export function addSummaryToSummary(
addedPackages: Changes | null,
invalidLicenseChanges: Record<string, Changes> | null
): void {
core.summary
.addHeading('Dependency Review')
.addRaw('We found:')
.addList([
...(addedPackages
? [`${addedPackages.length} vulnerable package(s)`]
: []),
...(invalidLicenseChanges
? [
`${invalidLicenseChanges.unresolved.length} package(s) with invalid SPDX license definitions`,
`${invalidLicenseChanges.forbidden.length} package(s) with incompatible licenses`,
`${invalidLicenseChanges.unlicensed.length} package(s) with unknown licenses.`
]
: [])
])
const icons = {
check: '✅',
cross: '❌',
warning: '⚠️'
}
export function addChangeVulnerabilitiesToSummary(
addedPackages: Changes,
severity: string
export function addSummaryToSummary(
vulnerableChanges: Changes,
invalidLicenseChanges: InvalidLicenseChanges,
config: ConfigurationOptions
): void {
const rows: SummaryTableRow[] = []
core.summary.addHeading('Dependency Review', 1)
const manifests = getManifestsSet(addedPackages)
if (
vulnerableChanges.length === 0 &&
countLicenseIssues(invalidLicenseChanges) === 0
) {
if (!config.license_check) {
core.summary.addRaw(`${icons.check} No vulnerabilities found.`)
} else if (!config.vulnerability_check) {
core.summary.addRaw(`${icons.check} No license issues found.`)
} else {
core.summary.addRaw(
`${icons.check} No vulnerabilities or license issues found.`
)
}
core.summary
.addHeading('Vulnerabilities')
.addQuote(
`Vulnerabilities were filtered by minimum severity <strong>${severity}</strong>.`
)
if (addedPackages.length === 0) {
core.summary.addQuote('No vulnerabilities found in added packages.')
return
}
core.summary
.addRaw('The following issues were found:')
.addList([
...(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.`
]
: [])
])
.addRaw('See the Details below.')
}
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 addedPackages.filter(
for (const change of vulnerableChanges.filter(
pkg => pkg.manifest === manifest
)) {
let previous_package = ''
@@ -72,7 +104,7 @@ export function addChangeVulnerabilitiesToSummary(
previous_version = change.version
}
}
core.summary.addHeading(`<em>${manifest}</em>`, 3).addTable([
core.summary.addHeading(`<em>${manifest}</em>`, 4).addTable([
[
{data: 'Name', header: true},
{data: 'Version', header: true},
@@ -82,13 +114,24 @@ export function addChangeVulnerabilitiesToSummary(
...rows
])
}
if (severity !== 'low') {
core.summary.addQuote(
`Only included vulnerabilities with severity <strong>${severity}</strong> or higher.`
)
}
}
export function addLicensesToSummary(
invalidLicenseChanges: Record<string, Changes>,
invalidLicenseChanges: InvalidLicenseChanges,
config: ConfigurationOptions
): void {
core.summary.addHeading('Licenses')
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(
@@ -101,11 +144,6 @@ export function addLicensesToSummary(
)
}
if (Object.values(invalidLicenseChanges).every(item => item.length === 0)) {
core.summary.addQuote('No license violations detected.')
return
}
core.debug(
`found ${invalidLicenseChanges.unlicensed.length} unknown licenses`
)
@@ -113,39 +151,43 @@ export function addLicensesToSummary(
core.debug(
`${invalidLicenseChanges.unresolved.length} licenses could not be validated`
)
printLicenseViolation(
'Incompatible Licenses',
invalidLicenseChanges.forbidden
)
printLicenseViolation('Unknown Licenses', invalidLicenseChanges.unlicensed)
printLicenseViolation(
'Invalid SPDX License Definitions',
invalidLicenseChanges.unresolved
)
}
function printLicenseViolation(heading: string, changes: Changes): void {
core.summary.addHeading(heading, 5).addSeparator()
if (changes.length > 0) {
const rows: SummaryTableRow[] = []
const manifests = getManifestsSet(changes)
const licenseIssueTypes: InvalidLicenseChangeTypes[] = [
'forbidden',
'unresolved',
'unlicensed'
]
for (const manifest of manifests) {
core.summary.addHeading(`<em>${manifest}</em>`, 4)
const issueTypeNames: Record<InvalidLicenseChangeTypes, string> = {
forbidden: 'Incompatible License',
unresolved: 'Invalid SPDX License',
unlicensed: 'Unknown License'
}
for (const change of changes.filter(pkg => pkg.manifest === manifest)) {
rows.push([
renderUrl(change.source_repository_url, change.name),
change.version,
formatLicense(change.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] = []
}
core.summary.addTable([['Package', 'Version', 'License'], ...rows])
rowsGroupedByManifest[change.manifest].push([
renderUrl(change.source_repository_url, change.name),
change.version,
formatLicense(change.license),
issueTypeNames[issueType]
])
}
} else {
core.summary.addQuote(`No ${heading.toLowerCase()} detected.`)
}
for (const [manifest, rows] of Object.entries(rowsGroupedByManifest)) {
core.summary.addHeading(`<em>${manifest}</em>`, 4)
core.summary.addTable([
['Package', 'Version', 'License', 'Issue Type'],
...rows
])
}
}
@@ -160,9 +202,7 @@ export function addScannedDependencies(changes: Changes): void {
const dependencies = groupDependenciesByManifest(changes)
const manifests = dependencies.keys()
const summary = core.summary
.addHeading('Scanned Dependencies')
.addHeading(`We scanned ${dependencies.size} manifest files:`, 5)
const summary = core.summary.addHeading('Scanned Manifest Files', 2)
for (const manifest of manifests) {
const deps = dependencies.get(manifest)
@@ -174,3 +214,20 @@ export function addScannedDependencies(changes: Changes): void {
}
}
}
function countLicenseIssues(
invalidLicenseChanges: InvalidLicenseChanges
): number {
return Object.values(invalidLicenseChanges).reduce(
(acc, val) => acc + val.length,
0
)
}
function checkOrFailIcon(count: number): string {
return count === 0 ? icons.check : icons.cross
}
function checkOrWarnIcon(count: number): string {
return count === 0 ? icons.check : icons.warning
}

8
tsconfig.build.json Normal file
View File

@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"include": ["src"],
"compilerOptions": {
"outDir": "./lib" /* Redirect output structure to the directory. */,
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
}
}

View File

@@ -3,10 +3,9 @@
"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */,
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
"outDir": "./lib" /* Redirect output structure to the directory. */,
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
"strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
},
"exclude": ["node_modules", "**/*.test.ts"]
"exclude": ["node_modules"]
}