Compare commits

..
Author SHA1 Message Date
Federico Builes 605a14dcda adding dist 2023-02-16 14:41:52 +01:00
24 changed files with 876 additions and 2096 deletions
-2
View File
@@ -100,5 +100,3 @@ Thumbs.db
# Ignore built ts files # Ignore built ts files
__tests__/runner/* __tests__/runner/*
lib/**/* lib/**/*
tmp
+4 -5
View File
@@ -1,7 +1,7 @@
# dependency-review-action # dependency-review-action
This action scans your pull requests for dependency changes, and will This action scans your pull requests for dependency changes, and will
raise an error if any vulnerabilities or invalid licenses are being introduced. The action is supported by an [API endpoint](https://docs.github.com/en/rest/reference/dependency-graph#dependency-review) that diffs the dependencies between any two revisions on your default branch. raise an error if any vulnerabilities or invalid licenses are being introduced. The action is supported by an [API endpoint](https://docs.github.com/en/rest/reference/dependency-graph#dependency-review) that diffs the dependencies between any two revisions.
The action is available for all public repositories, as well as private repositories that have GitHub Advanced Security licensed. The action is available for all public repositories, as well as private repositories that have GitHub Advanced Security licensed.
@@ -128,12 +128,11 @@ Start by specifying that you will be using an external configuration file:
config-file: './.github/dependency-review-config.yml' config-file: './.github/dependency-review-config.yml'
``` ```
And then create the file in the path you just specified. Please note And then create the file in the path you just specified:
that the **option names in external files use underscores instead of dashes**:
```yaml ```yaml
fail_on_severity: 'critical' fail-on-severity: 'critical'
allow_licenses: allow-licenses:
- 'GPL-3.0' - 'GPL-3.0'
- 'BSD-3-Clause' - 'BSD-3-Clause'
- 'MIT' - 'MIT'
+92 -1
View File
@@ -2,7 +2,34 @@ import {expect, test, beforeEach} from '@jest/globals'
import {readConfig} from '../src/config' import {readConfig} from '../src/config'
import {getRefs} from '../src/git-refs' import {getRefs} from '../src/git-refs'
import * as Utils from '../src/utils' import * as Utils from '../src/utils'
import {setInput, clearInputs} from './test-helpers'
// 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) {
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() {
const allowedOptions = [
'FAIL-ON-SEVERITY',
'FAIL-ON-SCOPES',
'ALLOW-LICENSES',
'DENY-LICENSES',
'ALLOW-GHSAS',
'LICENSE-CHECK',
'VULNERABILITY-CHECK',
'CONFIG-FILE',
'BASE-REF',
'HEAD-REF',
'COMMENT-SUMMARY-IN-PR'
]
allowedOptions.forEach(option => {
delete process.env[`INPUT_${option.toUpperCase()}`]
})
}
beforeAll(() => { beforeAll(() => {
jest.spyOn(Utils, 'isSPDXValid').mockReturnValue(true) jest.spyOn(Utils, 'isSPDXValid').mockReturnValue(true)
@@ -77,6 +104,60 @@ test('it raises an error when no refs are provided and the event is not a pull r
).toThrow() ).toThrow()
}) })
test('it reads an external config file', async () => {
setInput('config-file', './__tests__/fixtures/config-allow-sample.yml')
const config = await readConfig()
expect(config.fail_on_severity).toEqual('critical')
expect(config.allow_licenses).toEqual(['BSD', 'GPL 2'])
})
test('raises an error when the config file was not found', async () => {
setInput('config-file', 'fixtures/i-dont-exist')
await expect(readConfig()).rejects.toThrow(/Unable to fetch/)
})
test('it parses options from both sources', async () => {
setInput('config-file', './__tests__/fixtures/config-allow-sample.yml')
let config = await readConfig()
expect(config.fail_on_severity).toEqual('critical')
setInput('base-ref', 'a-custom-base-ref')
config = await readConfig()
expect(config.base_ref).toEqual('a-custom-base-ref')
})
test('in case of conflicts, the inline config is the source of truth', async () => {
setInput('fail-on-severity', 'low')
setInput('config-file', './__tests__/fixtures/config-allow-sample.yml') // this will set fail-on-severity to 'critical'
const config = await readConfig()
expect(config.fail_on_severity).toEqual('low')
})
test('it uses the default values when loading external files', async () => {
setInput('config-file', './__tests__/fixtures/no-licenses-config.yml')
let config = await readConfig()
expect(config.allow_licenses).toEqual(undefined)
expect(config.deny_licenses).toEqual(undefined)
setInput('config-file', './__tests__/fixtures/license-config-sample.yml')
config = await readConfig()
expect(config.fail_on_severity).toEqual('low')
})
test('it accepts an external configuration filename', async () => {
setInput('config-file', './__tests__/fixtures/no-licenses-config.yml')
const config = await readConfig()
expect(config.fail_on_severity).toEqual('critical')
})
test('it raises an error when given an unknown severity in an external config file', async () => {
setInput('config-file', './__tests__/fixtures/invalid-severity-config.yml')
await expect(readConfig()).rejects.toThrow()
})
test('it defaults to runtime scope', async () => { test('it defaults to runtime scope', async () => {
const config = await readConfig() const config = await readConfig()
expect(config.fail_on_scopes).toEqual(['runtime']) expect(config.fail_on_scopes).toEqual(['runtime'])
@@ -152,6 +233,16 @@ test('it is not possible to disable both checks', async () => {
) )
}) })
test('it supports comma-separated lists', async () => {
setInput(
'config-file',
'./__tests__/fixtures/inline-license-config-sample.yml'
)
let config = await readConfig()
expect(config.allow_licenses).toEqual(['MIT', 'GPL-2.0-only'])
})
describe('licenses that are not valid SPDX licenses', () => { describe('licenses that are not valid SPDX licenses', () => {
beforeAll(() => { beforeAll(() => {
jest.spyOn(Utils, 'isSPDXValid').mockReturnValue(false) jest.spyOn(Utils, 'isSPDXValid').mockReturnValue(false)
-111
View File
@@ -1,111 +0,0 @@
import {expect, test, beforeEach} from '@jest/globals'
import {readConfig} from '../src/config'
import * as Utils from '../src/utils'
import {setInput, clearInputs} from './test-helpers'
const externalConfig = `fail_on_severity: 'high'
allow_licenses: ['GPL-2.0-only']
`
const mockOctokit = {
rest: {
repos: {
getContent: jest.fn().mockReturnValue({data: externalConfig})
}
}
}
jest.mock('octokit', () => {
return {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
Octokit: class {
constructor() {
return mockOctokit
}
}
}
})
beforeAll(() => {
jest.spyOn(Utils, 'isSPDXValid').mockReturnValue(true)
})
beforeEach(() => {
clearInputs()
})
test('it reads an external config file', async () => {
setInput('config-file', './__tests__/fixtures/config-allow-sample.yml')
const config = await readConfig()
expect(config.fail_on_severity).toEqual('critical')
expect(config.allow_licenses).toEqual(['BSD', 'GPL 2'])
})
test('raises an error when the config file was not found', async () => {
setInput('config-file', 'fixtures/i-dont-exist')
await expect(readConfig()).rejects.toThrow(/Unable to fetch/)
})
test('it parses options from both sources', async () => {
setInput('config-file', './__tests__/fixtures/config-allow-sample.yml')
let config = await readConfig()
expect(config.fail_on_severity).toEqual('critical')
setInput('base-ref', 'a-custom-base-ref')
config = await readConfig()
expect(config.base_ref).toEqual('a-custom-base-ref')
})
test('in case of conflicts, the inline config is the source of truth', async () => {
setInput('fail-on-severity', 'low')
setInput('config-file', './__tests__/fixtures/config-allow-sample.yml') // this will set fail-on-severity to 'critical'
const config = await readConfig()
expect(config.fail_on_severity).toEqual('low')
})
test('it uses the default values when loading external files', async () => {
setInput('config-file', './__tests__/fixtures/no-licenses-config.yml')
let config = await readConfig()
expect(config.allow_licenses).toEqual(undefined)
expect(config.deny_licenses).toEqual(undefined)
setInput('config-file', './__tests__/fixtures/license-config-sample.yml')
config = await readConfig()
expect(config.fail_on_severity).toEqual('low')
})
test('it accepts an external configuration filename', async () => {
setInput('config-file', './__tests__/fixtures/no-licenses-config.yml')
const config = await readConfig()
expect(config.fail_on_severity).toEqual('critical')
})
test('it raises an error when given an unknown severity in an external config file', async () => {
setInput('config-file', './__tests__/fixtures/invalid-severity-config.yml')
await expect(readConfig()).rejects.toThrow()
})
test('it supports comma-separated lists', async () => {
setInput(
'config-file',
'./__tests__/fixtures/inline-license-config-sample.yml'
)
const config = await readConfig()
expect(config.allow_licenses).toEqual(['MIT', 'GPL-2.0-only'])
})
test('it reads a config file hosted in another repo', async () => {
setInput(
'config-file',
'future-funk/anyone-cualkiera/external-config.yml@main'
)
setInput('external-repo-token', 'gh_viptoken')
const config = await readConfig()
expect(config.fail_on_severity).toEqual('high')
expect(config.allow_licenses).toEqual(['GPL-2.0-only'])
})
+5 -5
View File
@@ -1,12 +1,12 @@
import {expect, test} from '@jest/globals' import {expect, test} from '@jest/globals'
import {Change} from '../src/schemas' import {Change, Changes} from '../src/schemas'
import { import {
filterChangesBySeverity, filterChangesBySeverity,
filterChangesByScopes, filterChangesByScopes,
filterAllowedAdvisories filterAllowedAdvisories
} from '../src/filter' } from '../src/filter'
const npmChange: Change = { let npmChange: Change = {
manifest: 'package.json', manifest: 'package.json',
change_type: 'added', change_type: 'added',
ecosystem: 'npm', ecosystem: 'npm',
@@ -26,7 +26,7 @@ const npmChange: Change = {
] ]
} }
const rubyChange: Change = { let rubyChange: Change = {
change_type: 'added', change_type: 'added',
manifest: 'Gemfile.lock', manifest: 'Gemfile.lock',
ecosystem: 'rubygems', ecosystem: 'rubygems',
@@ -52,7 +52,7 @@ const rubyChange: Change = {
] ]
} }
const noVulnNpmChange: Change = { let noVulnNpmChange: Change = {
manifest: 'package.json', manifest: 'package.json',
change_type: 'added', change_type: 'added',
ecosystem: 'npm', ecosystem: 'npm',
@@ -92,7 +92,7 @@ test('it properly filters changes by scope', async () => {
test('it properly handles undefined advisory IDs', async () => { test('it properly handles undefined advisory IDs', async () => {
const changes = [npmChange, rubyChange, noVulnNpmChange] const changes = [npmChange, rubyChange, noVulnNpmChange]
const result = filterAllowedAdvisories(undefined, changes) let result = filterAllowedAdvisories(undefined, changes)
expect(result).toEqual([npmChange, rubyChange, noVulnNpmChange]) expect(result).toEqual([npmChange, rubyChange, noVulnNpmChange])
}) })
-36
View File
@@ -1,36 +0,0 @@
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/[email protected]',
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}
@@ -1,19 +0,0 @@
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}
@@ -1 +1 @@
allow-licenses: "MIT, GPL-2.0-only" allow-licenses: MIT, GPL-2.0-only
@@ -1,3 +1,3 @@
fail_on_severity: 'so many zombies' fail-on-severity: 'so many zombies'
deny_licenses: deny-licenses:
- MIT - MIT
+2 -5
View File
@@ -3,7 +3,7 @@ import {Change, Changes} from '../src/schemas'
let getInvalidLicenseChanges: Function let getInvalidLicenseChanges: Function
const npmChange: Change = { let npmChange: Change = {
manifest: 'package.json', manifest: 'package.json',
change_type: 'added', change_type: 'added',
ecosystem: 'npm', ecosystem: 'npm',
@@ -23,7 +23,7 @@ const npmChange: Change = {
] ]
} }
const rubyChange: Change = { let rubyChange: Change = {
change_type: 'added', change_type: 'added',
manifest: 'Gemfile.lock', manifest: 'Gemfile.lock',
ecosystem: 'rubygems', ecosystem: 'rubygems',
@@ -63,7 +63,6 @@ const mockOctokit = {
jest.mock('octokit', () => { jest.mock('octokit', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
Octokit: class { Octokit: class {
constructor() { constructor() {
return mockOctokit return mockOctokit
@@ -79,7 +78,6 @@ beforeEach(async () => {
// true for BSD, false for all others // true for BSD, false for all others
return jest.fn((license: string, _: string): boolean => license === 'BSD') return jest.fn((license: string, _: string): boolean => license === 'BSD')
}) })
// eslint-disable-next-line @typescript-eslint/no-require-imports
;({getInvalidLicenseChanges} = require('../src/licenses')) ;({getInvalidLicenseChanges} = require('../src/licenses'))
}) })
@@ -142,7 +140,6 @@ test('it adds all licenses to unresolved if it is unable to determine the validi
throw new Error('Some Error') throw new Error('Some Error')
}) })
}) })
// eslint-disable-next-line @typescript-eslint/no-require-imports
;({getInvalidLicenseChanges} = require('../src/licenses')) ;({getInvalidLicenseChanges} = require('../src/licenses'))
const changes: Changes = [npmChange, rubyChange] const changes: Changes = [npmChange, rubyChange]
const invalidLicenses = await getInvalidLicenseChanges(changes, { const invalidLicenses = await getInvalidLicenseChanges(changes, {
-305
View File
@@ -1,305 +0,0 @@
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')
})
-28
View File
@@ -1,28 +0,0 @@
// GitHub Action inputs come in the form of environment variables
// with an INPUT prefix (e.g. INPUT_FAIL-ON-SEVERITY)
export 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.
export function clearInputs(): void {
const allowedOptions = [
'FAIL-ON-SEVERITY',
'FAIL-ON-SCOPES',
'ALLOW-LICENSES',
'DENY-LICENSES',
'ALLOW-GHSAS',
'LICENSE-CHECK',
'VULNERABILITY-CHECK',
'CONFIG-FILE',
'BASE-REF',
'HEAD-REF',
'COMMENT-SUMMARY-IN-PR'
]
// eslint-disable-next-line github/array-foreach
allowedOptions.forEach(option => {
delete process.env[`INPUT_${option.toUpperCase()}`]
})
}
+2 -2
View File
@@ -1,5 +1,3 @@
# Avoid using default values for options here since they will
# end up overriding external configurations.
name: 'Dependency Review' name: 'Dependency Review'
description: 'Prevent the introduction of dependencies with known vulnerabilities' description: 'Prevent the introduction of dependencies with known vulnerabilities'
author: 'GitHub' author: 'GitHub'
@@ -11,9 +9,11 @@ inputs:
fail-on-severity: fail-on-severity:
description: Don't block PRs below this severity. Possible values are `low`, `moderate`, `high`, `critical`. description: Don't block PRs below this severity. Possible values are `low`, `moderate`, `high`, `critical`.
required: false required: false
default: 'low'
fail-on-scopes: fail-on-scopes:
description: Dependency scopes to block PRs on. Comma-separated list. Possible values are 'unknown', 'runtime', and 'development' (e.g. "runtime, development") description: Dependency scopes to block PRs on. Comma-separated list. Possible values are 'unknown', 'runtime', and 'development' (e.g. "runtime, development")
required: false required: false
default: 'runtime'
base-ref: base-ref:
description: The base git ref to be used for this check. Has a default value when the workflow event is `pull_request` or `pull_request_target`. Must be provided otherwise. description: The base git ref to be used for this check. Has a default value when the workflow event is `pull_request` or `pull_request_target`. Must be provided otherwise.
required: false required: false
Generated Vendored
+303 -453
View File
File diff suppressed because it is too large Load Diff
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
+381 -848
View File
File diff suppressed because it is too large Load Diff
+12 -12
View File
@@ -1,11 +1,11 @@
{ {
"name": "dependency-review-action", "name": "dependency-review-action",
"version": "3.0.4", "version": "3.0.3",
"private": true, "private": true,
"description": "A GitHub Action for Dependency Review", "description": "A GitHub Action for Dependency Review",
"main": "lib/main.js", "main": "lib/main.js",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json", "build": "tsc",
"format": "prettier --write '**/*.ts'", "format": "prettier --write '**/*.ts'",
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",
"lint": "eslint src/**/*.ts", "lint": "eslint src/**/*.ts",
@@ -30,32 +30,32 @@
"@octokit/plugin-retry": "^4.1.1", "@octokit/plugin-retry": "^4.1.1",
"@octokit/request-error": "^2.1.0", "@octokit/request-error": "^2.1.0",
"ansi-styles": "^6.2.1", "ansi-styles": "^6.2.1",
"got": "^12.6.0", "got": "^12.5.3",
"nodemon": "^2.0.22", "nodemon": "^2.0.20",
"octokit": "^2.0.14", "octokit": "^2.0.14",
"spdx-expression-parse": "^3.0.1", "spdx-expression-parse": "^3.0.1",
"spdx-satisfies": "^5.0.1", "spdx-satisfies": "^5.0.1",
"yaml": "^2.2.1", "yaml": "^2.2.1",
"zod": "^3.21.4" "zod": "^3.20.6"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^27.5.2", "@types/jest": "^27.5.2",
"@types/node": "^16.18.23", "@types/node": "^16.18.12",
"@typescript-eslint/eslint-plugin": "^5.48.1", "@typescript-eslint/eslint-plugin": "^5.48.1",
"@typescript-eslint/parser": "^5.48.0", "@typescript-eslint/parser": "^5.48.0",
"@types/spdx-expression-parse": "^3.0.2", "@types/spdx-expression-parse": "^3.0.2",
"@types/spdx-satisfies": "^0.1.0", "@types/spdx-satisfies": "^0.1.0",
"@typescript-eslint/eslint-plugin": "^5.57.0", "@typescript-eslint/eslint-plugin": "^5.51.0",
"@typescript-eslint/parser": "^5.57.0", "@typescript-eslint/parser": "^5.51.0",
"@vercel/ncc": "^0.36.1", "@vercel/ncc": "^0.36.1",
"esbuild-register": "^3.4.2", "esbuild-register": "^3.4.2",
"eslint": "^8.37.0", "eslint": "^8.34.0",
"eslint-plugin-github": "^4.7.0", "eslint-plugin-github": "^4.6.0",
"eslint-plugin-jest": "^27.2.1", "eslint-plugin-jest": "^27.2.1",
"jest": "^27.5.1", "jest": "^27.5.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"nodemon": "^2.0.22", "nodemon": "^2.0.20",
"prettier": "2.8.7", "prettier": "2.8.4",
"ts-jest": "^27.1.4", "ts-jest": "^27.1.4",
"typescript": "^4.9.5" "typescript": "^4.9.5"
} }
-114
View File
@@ -1,114 +0,0 @@
/**
* 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()
+2 -7
View File
@@ -14,24 +14,19 @@ import {isSPDXValid, octokitClient} from './utils'
* @param { { allow?: string[], deny?: string[]}} licenses An object with `allow`/`deny` keys, each containing a list of licenses. * @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 * @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( export async function getInvalidLicenseChanges(
changes: Change[], changes: Change[],
licenses: { licenses: {
allow?: string[] allow?: string[]
deny?: string[] deny?: string[]
} }
): Promise<InvalidLicenseChanges> { ): Promise<Record<string, Changes>> {
const {allow, deny} = licenses const {allow, deny} = licenses
const groupedChanges = await groupChanges(changes) const groupedChanges = await groupChanges(changes)
const licensedChanges: Changes = groupedChanges.licensed const licensedChanges: Changes = groupedChanges.licensed
const invalidLicenseChanges: InvalidLicenseChanges = { const invalidLicenseChanges: Record<string, Changes> = {
unlicensed: groupedChanges.unlicensed, unlicensed: groupedChanges.unlicensed,
unresolved: [], unresolved: [],
forbidden: [] forbidden: []
+5 -11
View File
@@ -29,11 +29,6 @@ async function run(): Promise<void> {
headRef: refs.head headRef: refs.head
}) })
if (!changes) {
core.info('No Dependency Changes found. Skipping Dependency Review.')
return
}
const minSeverity = config.fail_on_severity const minSeverity = config.fail_on_severity
const scopedChanges = filterChangesByScopes(config.fail_on_scopes, changes) const scopedChanges = filterChangesByScopes(config.fail_on_scopes, changes)
const filteredChanges = filterAllowedAdvisories( const filteredChanges = filterAllowedAdvisories(
@@ -41,7 +36,7 @@ async function run(): Promise<void> {
scopedChanges scopedChanges
) )
const vulnerableChanges = filterChangesBySeverity( const addedChanges = filterChangesBySeverity(
minSeverity, minSeverity,
filteredChanges filteredChanges
).filter( ).filter(
@@ -60,14 +55,13 @@ async function run(): Promise<void> {
) )
summary.addSummaryToSummary( summary.addSummaryToSummary(
vulnerableChanges, config.vulnerability_check ? addedChanges : null,
invalidLicenseChanges, config.license_check ? invalidLicenseChanges : null
config
) )
if (config.vulnerability_check) { if (config.vulnerability_check) {
summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity) summary.addChangeVulnerabilitiesToSummary(addedChanges, minSeverity)
printVulnerabilitiesBlock(vulnerableChanges, minSeverity) printVulnerabilitiesBlock(addedChanges, minSeverity)
} }
if (config.license_check) { if (config.license_check) {
summary.addLicensesToSummary(invalidLicenseChanges, config) summary.addLicensesToSummary(invalidLicenseChanges, config)
+62 -119
View File
@@ -1,82 +1,50 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {ConfigurationOptions, Changes} from './schemas' import {ConfigurationOptions, Changes} from './schemas'
import {SummaryTableRow} from '@actions/core/lib/summary' import {SummaryTableRow} from '@actions/core/lib/summary'
import {InvalidLicenseChanges, InvalidLicenseChangeTypes} from './licenses'
import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils' import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils'
const icons = {
check: '✅',
cross: '❌',
warning: '⚠️'
}
export function addSummaryToSummary( export function addSummaryToSummary(
vulnerableChanges: Changes, addedPackages: Changes | null,
invalidLicenseChanges: InvalidLicenseChanges, invalidLicenseChanges: Record<string, Changes> | null
config: ConfigurationOptions
): void { ): void {
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 core.summary
.addRaw('The following issues were found:') .addHeading('Dependency Review')
.addRaw('We found:')
.addList([ .addList([
...(config.vulnerability_check ...(addedPackages
? [ ? [`${addedPackages.length} vulnerable package(s)`]
`${checkOrFailIcon(vulnerableChanges.length)} ${
vulnerableChanges.length
} vulnerable package(s)`
]
: []), : []),
...(config.license_check ...(invalidLicenseChanges
? [ ? [
`${checkOrFailIcon(invalidLicenseChanges.forbidden.length)} ${ `${invalidLicenseChanges.unresolved.length} package(s) with invalid SPDX license definitions`,
invalidLicenseChanges.forbidden.length `${invalidLicenseChanges.forbidden.length} package(s) with incompatible licenses`,
} package(s) with incompatible licenses`, `${invalidLicenseChanges.unlicensed.length} package(s) with unknown 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( export function addChangeVulnerabilitiesToSummary(
vulnerableChanges: Changes, addedPackages: Changes,
severity: string severity: string
): void { ): void {
if (vulnerableChanges.length === 0) { const rows: SummaryTableRow[] = []
const manifests = 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.')
return return
} }
const rows: SummaryTableRow[] = []
const manifests = getManifestsSet(vulnerableChanges)
core.summary.addHeading('Vulnerabilities', 2)
for (const manifest of manifests) { for (const manifest of manifests) {
for (const change of vulnerableChanges.filter( for (const change of addedPackages.filter(
pkg => pkg.manifest === manifest pkg => pkg.manifest === manifest
)) { )) {
let previous_package = '' let previous_package = ''
@@ -104,7 +72,7 @@ export function addChangeVulnerabilitiesToSummary(
previous_version = change.version previous_version = change.version
} }
} }
core.summary.addHeading(`<em>${manifest}</em>`, 4).addTable([ core.summary.addHeading(`<em>${manifest}</em>`, 3).addTable([
[ [
{data: 'Name', header: true}, {data: 'Name', header: true},
{data: 'Version', header: true}, {data: 'Version', header: true},
@@ -114,24 +82,13 @@ export function addChangeVulnerabilitiesToSummary(
...rows ...rows
]) ])
} }
if (severity !== 'low') {
core.summary.addQuote(
`Only included vulnerabilities with severity <strong>${severity}</strong> or higher.`
)
}
} }
export function addLicensesToSummary( export function addLicensesToSummary(
invalidLicenseChanges: InvalidLicenseChanges, invalidLicenseChanges: Record<string, Changes>,
config: ConfigurationOptions config: ConfigurationOptions
): void { ): void {
if (countLicenseIssues(invalidLicenseChanges) === 0) { core.summary.addHeading('Licenses')
return
}
core.summary.addHeading('License Issues', 2)
printLicenseViolations(invalidLicenseChanges)
if (config.allow_licenses && config.allow_licenses.length > 0) { if (config.allow_licenses && config.allow_licenses.length > 0) {
core.summary.addQuote( core.summary.addQuote(
@@ -144,6 +101,11 @@ export function addLicensesToSummary(
) )
} }
if (Object.values(invalidLicenseChanges).every(item => item.length === 0)) {
core.summary.addQuote('No license violations detected.')
return
}
core.debug( core.debug(
`found ${invalidLicenseChanges.unlicensed.length} unknown licenses` `found ${invalidLicenseChanges.unlicensed.length} unknown licenses`
) )
@@ -151,43 +113,39 @@ export function addLicensesToSummary(
core.debug( core.debug(
`${invalidLicenseChanges.unresolved.length} licenses could not be validated` `${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()
const licenseIssueTypes: InvalidLicenseChangeTypes[] = [ if (changes.length > 0) {
'forbidden', const rows: SummaryTableRow[] = []
'unresolved', const manifests = getManifestsSet(changes)
'unlicensed'
]
const issueTypeNames: Record<InvalidLicenseChangeTypes, string> = { for (const manifest of manifests) {
forbidden: 'Incompatible License', core.summary.addHeading(`<em>${manifest}</em>`, 4)
unresolved: 'Invalid SPDX License',
unlicensed: 'Unknown License'
}
function printLicenseViolations(changes: InvalidLicenseChanges): void { for (const change of changes.filter(pkg => pkg.manifest === manifest)) {
const rowsGroupedByManifest: Record<string, SummaryTableRow[]> = {} rows.push([
renderUrl(change.source_repository_url, change.name),
for (const issueType of licenseIssueTypes) { change.version,
for (const change of changes[issueType]) { formatLicense(change.license)
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.addTable([['Package', 'Version', 'License'], ...rows])
core.summary.addHeading(`<em>${manifest}</em>`, 4) }
core.summary.addTable([ } else {
['Package', 'Version', 'License', 'Issue Type'], core.summary.addQuote(`No ${heading.toLowerCase()} detected.`)
...rows
])
} }
} }
@@ -202,7 +160,9 @@ export function addScannedDependencies(changes: Changes): void {
const dependencies = groupDependenciesByManifest(changes) const dependencies = groupDependenciesByManifest(changes)
const manifests = dependencies.keys() const manifests = dependencies.keys()
const summary = core.summary.addHeading('Scanned Manifest Files', 2) const summary = core.summary
.addHeading('Scanned Dependencies')
.addHeading(`We scanned ${dependencies.size} manifest files:`, 5)
for (const manifest of manifests) { for (const manifest of manifests) {
const deps = dependencies.get(manifest) const deps = dependencies.get(manifest)
@@ -214,20 +174,3 @@ 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
View File
@@ -1,8 +0,0 @@
{
"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. */
}
}
+2 -1
View File
@@ -3,9 +3,10 @@
"target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, "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'. */, "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */,
"outDir": "./lib" /* Redirect output structure to the directory. */, "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. */, "strict": true /* Enable all strict type-checking options. */,
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, "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'. */ "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
}, },
"exclude": ["node_modules"] "exclude": ["node_modules", "**/*.test.ts"]
} }