Merge pull request #294 from actions/cn/spdx-licenses

Add support for SPDX expressions
This commit is contained in:
Federico Builes
2022-10-28 11:27:18 +02:00
committed by GitHub
14 changed files with 5601 additions and 9702 deletions
+11 -11
View File
@@ -106,19 +106,20 @@ fail-on-scopes:
### allow-licenses ### allow-licenses
Only allow the licenses in this list. See "[Licenses](https://github.com/actions/dependency-review-action#licenses)". Only allow the licenses that comply with the expressions in this list. See "[Licenses](https://github.com/actions/dependency-review-action#licenses)".
**Possible values**: Any `spdx_id` value(s) from **Possible values**: A list of of [SPDX-compliant license identifiers](https://spdx.org/licenses/).
https://docs.github.com/en/rest/licenses.
**Inline example**: `allow-licenses: BSD-3-Clause, MIT` **Inline example**: `allow-licenses: BSD-3-Clause, LGPL-2.1 OR MIT OR BSD-3-Clause`
**YAML example**: **YAML example**:
```yaml ```yaml
allow-licenses: allow-licenses:
- BSD-3-Clause - BSD-3-Clause
- LGPL-2.1
- MIT - MIT
- BSD-3-Clause
``` ```
### deny-licenses ### deny-licenses
@@ -126,17 +127,16 @@ allow-licenses:
Add a custom list of licenses you want to block. See Add a custom list of licenses you want to block. See
"[Licenses](https://github.com/actions/dependency-review-action#licenses)". "[Licenses](https://github.com/actions/dependency-review-action#licenses)".
**Possible values**: Any `spdx_id` value(s) from **Possible values**: Any valid set of [SPDX licenses](https://spdx.org/licenses/).
https://docs.github.com/en/rest/licenses.
**Inline example**: `deny-licenses: LGPL-2.0, BSD-2-Clause` **Inline example**: `deny-licenses: LGPL-2.0, GPL-2.0+ WITH Bison-exception-2.2`
**YAML example**: **YAML example**:
```yaml ```yaml
deny-licenses: deny-licenses:
- LGPL-2.0 - LGPL-2.0
- BSD-2-Clause - GPL-2.0+ WITH Bison-exception-2.2
``` ```
### allow-ghsas ### allow-ghsas
@@ -259,8 +259,8 @@ forbid a subset of licenses. These options are not supported on Enterprise Serve
You can use the [Licenses You can use the [Licenses
API](https://docs.github.com/en/rest/licenses) to see the full list of API](https://docs.github.com/en/rest/licenses) to see the full list of
supported licenses. Use the `spdx_id` field for every license you want supported licenses. Use [SPDX licenses](https://spdx.org/licenses/)
to filter. A couple of examples: to filter the licenses. A couple of examples:
```yaml ```yaml
# only allow MIT-licensed dependents # only allow MIT-licensed dependents
@@ -275,7 +275,7 @@ to filter. A couple of examples:
- name: Dependency Review - name: Dependency Review
uses: actions/dependency-review-action@v2 uses: actions/dependency-review-action@v2
with: with:
deny-licenses: Apache-1.1, Apache-2.0 deny-licenses: Apache-1.1+
``` ```
### Considerations ### Considerations
+25
View File
@@ -1,6 +1,7 @@
import {expect, test, beforeEach} from '@jest/globals' import {expect, test, beforeEach} from '@jest/globals'
import {readConfig, readConfigFile} from '../src/config' import {readConfig, readConfigFile} from '../src/config'
import {getRefs} from '../src/git-refs' import {getRefs} from '../src/git-refs'
import * as Utils from '../src/utils'
// GitHub Action inputs come in the form of environment variables // GitHub Action inputs come in the form of environment variables
// with an INPUT prefix (e.g. INPUT_FAIL-ON-SEVERITY) // with an INPUT prefix (e.g. INPUT_FAIL-ON-SEVERITY)
@@ -27,6 +28,10 @@ function clearInputs() {
}) })
} }
beforeAll(() => {
jest.spyOn(Utils, 'isSPDXValid').mockReturnValue(true)
})
beforeEach(() => { beforeEach(() => {
clearInputs() clearInputs()
}) })
@@ -175,3 +180,23 @@ test('it successfully parses GHSA allowlist', async () => {
'GHSA-efgh-1234-5679' 'GHSA-efgh-1234-5679'
]) ])
}) })
describe('licenses that are not valid SPDX licenses', () => {
beforeAll(() => {
jest.spyOn(Utils, 'isSPDXValid').mockReturnValue(false)
})
test('it raises an error for invalid licenses in allow-licenses', async () => {
setInput('allow-licenses', ' BSD, GPL 2')
expect(() => {
readConfig()
}).toThrow('Invalid license(s) in allow-licenses: BSD, GPL 2')
})
test('it raises an error for invalid licenses in deny-licenses', async () => {
setInput('deny-licenses', ' BSD, GPL 2')
expect(() => {
readConfig()
}).toThrow('Invalid license(s) in deny-licenses: BSD, GPL 2')
})
})
+57 -27
View File
@@ -1,6 +1,7 @@
import {expect, jest, test} from '@jest/globals' import {expect, jest, test} from '@jest/globals'
import {Change, Changes} from '../src/schemas' import {Change, Changes} from '../src/schemas'
import {getDeniedLicenseChanges} from '../src/licenses'
let getInvalidLicenseChanges: Function
let npmChange: Change = { let npmChange: Change = {
manifest: 'package.json', manifest: 'package.json',
@@ -70,65 +71,94 @@ jest.mock('octokit', () => {
} }
}) })
test('it fails if a license outside the allow list is found', async () => { beforeEach(async () => {
const changes: Changes = [npmChange, rubyChange] jest.resetModules()
const [invalidChanges, _] = await getDeniedLicenseChanges(changes, { jest.doMock('spdx-satisfies', () => {
allow: ['BSD'] // mock spdx-satisfies return value
// true for BSD, false for all others
return jest.fn((license: string, _: string): boolean => license === 'BSD')
}) })
expect(invalidChanges[0]).toBe(npmChange) ;({getInvalidLicenseChanges} = require('../src/licenses'))
}) })
test('it fails if a license inside the deny list is found', async () => { test('it adds license outside the allow list to forbidden changes', async () => {
const changes: Changes = [npmChange, rubyChange] const changes: Changes = [npmChange, rubyChange]
const [invalidChanges] = await getDeniedLicenseChanges(changes, { const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['BSD']
})
expect(forbidden[0]).toBe(npmChange)
expect(forbidden.length).toEqual(1)
})
test('it adds license inside the deny list to forbidden changes', async () => {
const changes: Changes = [npmChange, rubyChange]
const {forbidden} = await getInvalidLicenseChanges(changes, {
deny: ['BSD'] deny: ['BSD']
}) })
expect(invalidChanges[0]).toBe(rubyChange) expect(forbidden[0]).toBe(rubyChange)
expect(forbidden.length).toEqual(1)
}) })
// This is more of a "here's a behavior that might be surprising" than an actual // This is more of a "here's a behavior that might be surprising" than an actual
// thing we want in the system. Please remove this test after refactoring. // thing we want in the system. Please remove this test after refactoring.
test('it fails all license checks when allow is provided an empty array', async () => { test('it adds all licenses to forbidden changes when allow is provided an empty array', async () => {
const changes: Changes = [npmChange, rubyChange] const changes: Changes = [npmChange, rubyChange]
let [invalidChanges, _] = await getDeniedLicenseChanges(changes, { let {forbidden} = await getInvalidLicenseChanges(changes, {
allow: [], allow: [],
deny: ['BSD'] deny: ['BSD']
}) })
expect(invalidChanges.length).toBe(2) expect(forbidden.length).toBe(2)
}) })
test('it does not fail if a license outside the allow list is found in removed changes', async () => { test('it does not add license outside the allow list to forbidden changes if it is in removed changes', async () => {
const changes: Changes = [ const changes: Changes = [
{...npmChange, change_type: 'removed'}, {...npmChange, change_type: 'removed'},
{...rubyChange, change_type: 'removed'} {...rubyChange, change_type: 'removed'}
] ]
const [invalidChanges, _] = await getDeniedLicenseChanges(changes, { const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['BSD'] allow: ['BSD']
}) })
expect(invalidChanges).toStrictEqual([]) expect(forbidden).toStrictEqual([])
}) })
test('it does not fail if a license inside the deny list is found in removed changes', async () => { test('it does not add license inside the deny list to forbidden changes if it is in removed changes', async () => {
const changes: Changes = [ const changes: Changes = [
{...npmChange, change_type: 'removed'}, {...npmChange, change_type: 'removed'},
{...rubyChange, change_type: 'removed'} {...rubyChange, change_type: 'removed'}
] ]
const [invalidChanges, _] = await getDeniedLicenseChanges(changes, { const {forbidden} = await getInvalidLicenseChanges(changes, {
deny: ['BSD'] deny: ['BSD']
}) })
expect(invalidChanges).toStrictEqual([]) expect(forbidden).toStrictEqual([])
}) })
test('it fails if a license outside the allow list is found in both of added and removed changes', async () => { test('it adds license outside the allow list to forbidden changes if it is in both added and removed changes', async () => {
const changes: Changes = [ const changes: Changes = [
{...npmChange, change_type: 'removed'}, {...npmChange, change_type: 'removed'},
npmChange, npmChange,
{...rubyChange, change_type: 'removed'} {...rubyChange, change_type: 'removed'}
] ]
const [invalidChanges, _] = await getDeniedLicenseChanges(changes, { const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['BSD'] allow: ['BSD']
}) })
expect(invalidChanges).toStrictEqual([npmChange]) expect(forbidden).toStrictEqual([npmChange])
})
test('it adds all licenses to unresolved if it is unable to determine the validity', async () => {
jest.resetModules() // reset module set in before
jest.doMock('spdx-satisfies', () => {
return jest.fn((_first: string, _second: string) => {
throw new Error('Some Error')
})
})
;({getInvalidLicenseChanges} = require('../src/licenses'))
const changes: Changes = [npmChange, rubyChange]
const invalidLicenses = await getInvalidLicenseChanges(changes, {
allow: ['BSD']
})
expect(invalidLicenses.forbidden.length).toEqual(0)
expect(invalidLicenses.unlicensed.length).toEqual(0)
expect(invalidLicenses.unresolved.length).toEqual(2)
}) })
describe('GH License API fallback', () => { describe('GH License API fallback', () => {
@@ -138,7 +168,7 @@ describe('GH License API fallback', () => {
license: null, license: null,
source_repository_url: 'http://github.com/some-owner/some-repo' source_repository_url: 'http://github.com/some-owner/some-repo'
} }
const [_, unknownChanges] = await getDeniedLicenseChanges( const {unlicensed} = await getInvalidLicenseChanges(
[nullLicenseChange, rubyChange], [nullLicenseChange, rubyChange],
{} {}
) )
@@ -147,25 +177,25 @@ describe('GH License API fallback', () => {
owner: 'some-owner', owner: 'some-owner',
repo: 'some-repo' repo: 'some-repo'
}) })
expect(unknownChanges.length).toEqual(0) expect(unlicensed.length).toEqual(0)
}) })
test('it does not call licenses API endpoint for change with null license and invalid source_repository_url ', async () => { test('it does not call licenses API endpoint for change with null license and invalid source_repository_url ', async () => {
const [_, unknownChanges] = await getDeniedLicenseChanges( const {unlicensed} = await getInvalidLicenseChanges(
[{...npmChange, license: null}], [{...npmChange, license: null}],
{} {}
) )
expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled() expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled()
expect(unknownChanges.length).toEqual(1) expect(unlicensed.length).toEqual(1)
}) })
test('it does not call licenses API endpoint if licenses for all changes are present', async () => { test('it does not call licenses API endpoint if licenses for all changes are present', async () => {
const [_, unknownChanges] = await getDeniedLicenseChanges( const {unlicensed} = await getInvalidLicenseChanges(
[npmChange, rubyChange], [npmChange, rubyChange],
{} {}
) )
expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled() expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled()
expect(unknownChanges.length).toEqual(0) expect(unlicensed.length).toEqual(0)
}) })
}) })
Generated Vendored
+2368 -6407
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+1 -1
View File
File diff suppressed because one or more lines are too long
Generated Vendored
+108
View File
@@ -517,6 +517,31 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
array-find-index
MIT
The MIT License (MIT)
Copyright (c) Sindre Sorhus <[email protected]> (sindresorhus.com)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
before-after-hook before-after-hook
Apache-2.0 Apache-2.0
Apache License Apache License
@@ -1590,6 +1615,89 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR
IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
spdx-compare
MIT
The MIT License
Copyright (c) 2015 Kyle E. Mitchell
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
spdx-exceptions
CC-BY-3.0
spdx-expression-parse
MIT
The MIT License
Copyright (c) 2015 Kyle E. Mitchell & other authors listed in AUTHORS
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
spdx-license-ids
CC0-1.0
spdx-ranges
(MIT AND CC-BY-3.0)
The MIT License
Copyright (c) 2015 Kyle E. Mitchell
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
spdx-satisfies
MIT
The MIT License
Copyright (c) spdx-satisfies.js contributors
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
tr46 tr46
MIT MIT
+2 -2
View File
@@ -1,9 +1,9 @@
module.exports = { module.exports = {
clearMocks: true, clearMocks: true,
moduleFileExtensions: ['js', 'ts'], moduleFileExtensions: ['js', 'json', 'ts'],
testMatch: ['**/*.test.ts'], testMatch: ['**/*.test.ts'],
transform: { transform: {
'^.+\\.ts$': 'ts-jest' '^.+\\.ts$': 'ts-jest'
}, },
verbose: true verbose: true
} }
+2831 -3164
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -33,6 +33,8 @@
"got": "^12.5.2", "got": "^12.5.2",
"nodemon": "^2.0.20", "nodemon": "^2.0.20",
"octokit": "^2.0.10", "octokit": "^2.0.10",
"spdx-expression-parse": "^3.0.1",
"spdx-satisfies": "^5.0.1",
"yaml": "^2.1.3", "yaml": "^2.1.3",
"zod": "^3.19.1" "zod": "^3.19.1"
}, },
@@ -41,6 +43,8 @@
"@types/node": "^16.18.2", "@types/node": "^16.18.2",
"@typescript-eslint/eslint-plugin": "^5.41.0", "@typescript-eslint/eslint-plugin": "^5.41.0",
"@typescript-eslint/parser": "^5.41.0", "@typescript-eslint/parser": "^5.41.0",
"@types/spdx-expression-parse": "^3.0.2",
"@types/spdx-satisfies": "^0.1.0",
"@vercel/ncc": "^0.34.0", "@vercel/ncc": "^0.34.0",
"esbuild-register": "^3.3.3", "esbuild-register": "^3.3.3",
"eslint": "^8.26.0", "eslint": "^8.26.0",
+25 -1
View File
@@ -9,6 +9,9 @@ import {
SeveritySchema, SeveritySchema,
SCOPES SCOPES
} from './schemas' } from './schemas'
import {isSPDXValid} from './utils'
type licenseKey = 'allow-licenses' | 'deny-licenses'
function getOptionalInput(name: string): string | undefined { function getOptionalInput(name: string): string | undefined {
const value = core.getInput(name) const value = core.getInput(name)
@@ -23,6 +26,22 @@ function parseList(list: string | undefined): string[] | undefined {
} }
} }
function validateLicenses(
key: licenseKey,
licenses: string[] | undefined
): void {
if (licenses === undefined) {
return
}
const invalid_licenses = licenses.filter(license => !isSPDXValid(license))
if (invalid_licenses.length > 0) {
throw new Error(
`Invalid license(s) in ${key}: ${invalid_licenses.join(', ')}`
)
}
}
export function readConfig(): ConfigurationOptions { export function readConfig(): ConfigurationOptions {
const externalConfig = getOptionalInput('config-file') const externalConfig = getOptionalInput('config-file')
if (externalConfig !== undefined) { if (externalConfig !== undefined) {
@@ -53,6 +72,8 @@ export function readInlineConfig(): ConfigurationOptions {
if (allow_licenses !== undefined && deny_licenses !== undefined) { if (allow_licenses !== undefined && deny_licenses !== undefined) {
throw new Error("Can't specify both allow_licenses and deny_licenses") throw new Error("Can't specify both allow_licenses and deny_licenses")
} }
validateLicenses('allow-licenses', allow_licenses)
validateLicenses('deny-licenses', deny_licenses)
const allow_ghsas = parseList(getOptionalInput('allow-ghsas')) const allow_ghsas = parseList(getOptionalInput('allow-ghsas'))
@@ -80,8 +101,11 @@ export function readConfigFile(filePath: string): ConfigurationOptions {
} }
data = YAML.parse(data) data = YAML.parse(data)
// get rid of the ugly dashes from the actions conventions
for (const key of Object.keys(data)) { for (const key of Object.keys(data)) {
if (key === 'allow-licenses' || key === 'deny-licenses') {
validateLicenses(key, data[key])
}
// get rid of the ugly dashes from the actions conventions
if (key.includes('-')) { if (key.includes('-')) {
data[key.replace(/-/g, '_')] = data[key] data[key.replace(/-/g, '_')] = data[key]
delete data[key] delete data[key]
+91 -24
View File
@@ -1,6 +1,8 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import spdxSatisfies from 'spdx-satisfies'
import {Octokit} from 'octokit' import {Octokit} from 'octokit'
import {Change} from './schemas' import {Change, Changes} from './schemas'
import {isSPDXValid} from './utils'
/** /**
* Loops through a list of changes, filtering and returning the * Loops through a list of changes, filtering and returning the
@@ -12,48 +14,62 @@ import {Change} from './schemas'
* we will ignore the deny list. * we will ignore the deny list.
* @param {Change[]} changes The list of changes to filter. * @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. * @param { { allow?: string[], deny?: string[]}} licenses An object with `allow`/`deny` keys, each containing a list of licenses.
* @returns {Promise<[Array.<Change>, Array.<Change>]>} A promise to a 2 element tuple. The first element is the list of denied changes and the second one is the list of changes with unknown 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 async function getDeniedLicenseChanges( export async function getInvalidLicenseChanges(
changes: Change[], changes: Change[],
licenses: { licenses: {
allow?: string[] allow?: string[]
deny?: string[] deny?: string[]
} }
): Promise<[Change[], Change[]]> { ): Promise<Record<string, Changes>> {
const {allow, deny} = licenses const {allow, deny} = licenses
const disallowed: Change[] = [] const groupedChanges = await groupChanges(changes)
const unknown: Change[] = [] const licensedChanges: Changes = groupedChanges.licensed
const consolidatedChanges = changes.some( const invalidLicenseChanges: Record<string, Changes> = {
({source_repository_url, license}) => !license && source_repository_url unlicensed: groupedChanges.unlicensed,
) unresolved: [],
? await setGHLicenses(changes) forbidden: []
: changes }
for (const change of consolidatedChanges) { const validityCache = new Map<string, boolean>()
if (change.change_type === 'removed') {
continue
}
for (const change of licensedChanges) {
const license = change.license const license = change.license
// should never happen since licensedChanges always have licenses but license is nullable in changes schema
if (license === null) { if (license === null) {
unknown.push(change)
continue continue
} }
if (allow !== undefined) {
if (!allow.includes(license)) { if (license === 'NOASSERTION') {
disallowed.push(change) invalidLicenseChanges.unlicensed.push(change)
} } else if (validityCache.get(license) === undefined) {
} else if (deny !== undefined) { try {
if (deny.includes(license)) { if (allow !== undefined) {
disallowed.push(change) const found = allow.find(spdxExpression =>
spdxSatisfies(license, spdxExpression)
)
validityCache.set(license, found !== undefined)
} else if (deny !== undefined) {
const found = deny.find(spdxExpression =>
spdxSatisfies(license, spdxExpression)
)
validityCache.set(license, found === undefined)
}
} catch (err) {
invalidLicenseChanges.unresolved.push(change)
} }
} }
if (validityCache.get(license) === false) {
invalidLicenseChanges.forbidden.push(change)
}
} }
return [disallowed, unknown] return invalidLicenseChanges
} }
const fetchGHLicense = async ( const fetchGHLicense = async (
@@ -108,3 +124,54 @@ const setGHLicenses = async (changes: Change[]): Promise<Change[]> => {
return Promise.all(updatedChanges) return Promise.all(updatedChanges)
} }
// Currently Dependency Graph licenses are truncated to 255 characters
// This possibly makes them invalid spdx ids
const truncatedDGLicense = (license: string): boolean =>
license.length === 255 && !isSPDXValid(license)
async function groupChanges(
changes: Changes
): Promise<Record<string, Changes>> {
const result: Record<string, Changes> = {
licensed: [],
unlicensed: []
}
const ghChanges = []
for (const change of changes) {
if (change.change_type === 'removed') {
continue
}
if (change.license === null) {
if (change.source_repository_url !== null) {
ghChanges.push(change)
} else {
result.unlicensed.push(change)
}
} else {
if (
truncatedDGLicense(change.license) &&
change.source_repository_url !== null
) {
ghChanges.push(change)
} else {
result.licensed.push(change)
}
}
}
if (ghChanges.length > 0) {
const ghLicenses = await setGHLicenses(ghChanges)
for (const change of ghLicenses) {
if (change.license === null) {
result.unlicensed.push(change)
} else {
result.licensed.push(change)
}
}
}
return result
}
+23 -19
View File
@@ -10,7 +10,7 @@ import {
filterChangesByScopes, filterChangesByScopes,
filterAllowedAdvisories filterAllowedAdvisories
} from '../src/filter' } from '../src/filter'
import {getDeniedLicenseChanges} from './licenses' import {getInvalidLicenseChanges} from './licenses'
import * as summary from './summary' import * as summary from './summary'
import {getRefs} from './git-refs' import {getRefs} from './git-refs'
@@ -45,7 +45,7 @@ async function run(): Promise<void> {
change.vulnerabilities.length > 0 change.vulnerabilities.length > 0
) )
const [licenseErrors, unknownLicenses] = await getDeniedLicenseChanges( const invalidLicenseChanges = await getInvalidLicenseChanges(
filteredChanges, filteredChanges,
{ {
allow: config.allow_licenses, allow: config.allow_licenses,
@@ -53,13 +53,13 @@ async function run(): Promise<void> {
} }
) )
summary.addSummaryToSummary(addedChanges, licenseErrors, unknownLicenses) summary.addSummaryToSummary(addedChanges, invalidLicenseChanges)
summary.addChangeVulnerabilitiesToSummary(addedChanges, minSeverity) summary.addChangeVulnerabilitiesToSummary(addedChanges, minSeverity)
summary.addLicensesToSummary(licenseErrors, unknownLicenses, config) summary.addLicensesToSummary(invalidLicenseChanges, config)
summary.addScannedDependencies(changes) summary.addScannedDependencies(changes)
printVulnerabilitiesBlock(addedChanges, minSeverity) printVulnerabilitiesBlock(addedChanges, minSeverity)
printLicensesBlock(licenseErrors, unknownLicenses) printLicensesBlock(invalidLicenseChanges)
printScannedDependencies(changes) printScannedDependencies(changes)
} catch (error) { } catch (error) {
if (error instanceof RequestError && error.status === 404) { if (error instanceof RequestError && error.status === 404) {
@@ -83,7 +83,7 @@ async function run(): Promise<void> {
} }
function printVulnerabilitiesBlock( function printVulnerabilitiesBlock(
addedChanges: Change[], addedChanges: Changes,
minSeverity: Severity minSeverity: Severity
): void { ): void {
let failed = false let failed = false
@@ -119,24 +119,28 @@ function printChangeVulnerabilities(change: Change): void {
} }
function printLicensesBlock( function printLicensesBlock(
licenseErrors: Change[], invalidLicenseChanges: Record<string, Changes>
unknownLicenses: Change[]
): void { ): void {
core.group('Licenses', async () => { core.group('Licenses', async () => {
if (licenseErrors.length > 0) { if (invalidLicenseChanges.forbidden.length > 0) {
printLicensesError(licenseErrors) core.info('\nThe following dependencies have incompatible licenses:')
printLicensesError(invalidLicenseChanges.forbidden)
core.setFailed('Dependency review detected incompatible licenses.') core.setFailed('Dependency review detected incompatible licenses.')
} }
printNullLicenses(unknownLicenses) if (invalidLicenseChanges.unresolved.length > 0) {
core.warning(
'\nThe validity of the licenses of the dependencies below could not be determined. Ensure that they are valid SPDX licenses:'
)
printLicensesError(invalidLicenseChanges.unresolved)
core.setFailed(
'Dependency review could not detect the validity of all licenses.'
)
}
printNullLicenses(invalidLicenseChanges.unlicensed)
}) })
} }
function printLicensesError(changes: Change[]): void { function printLicensesError(changes: Changes): void {
if (changes.length === 0) {
return
}
core.info('\nThe following dependencies have incompatible licenses:\n')
for (const change of changes) { for (const change of changes) {
core.info( core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close} License: ${styles.color.red.open}${change.license}${styles.color.red.close}` `${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close} License: ${styles.color.red.open}${change.license}${styles.color.red.close}`
@@ -144,12 +148,12 @@ function printLicensesError(changes: Change[]): void {
} }
} }
function printNullLicenses(changes: Change[]): void { function printNullLicenses(changes: Changes): void {
if (changes.length === 0) { if (changes.length === 0) {
return return
} }
core.info('\nWe could not detect a license for the following dependencies:\n') core.info('\nWe could not detect a license for the following dependencies:')
for (const change of changes) { for (const change of changes) {
core.info( core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close}` `${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close}`
+45 -46
View File
@@ -1,18 +1,21 @@
import * as core from '@actions/core' import * as core from '@actions/core'
import {ConfigurationOptions, Change, Changes} from './schemas' import {ConfigurationOptions, Changes} from './schemas'
import {SummaryTableRow} from '@actions/core/lib/summary' import {SummaryTableRow} from '@actions/core/lib/summary'
import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils' import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils'
export function addSummaryToSummary( export function addSummaryToSummary(
addedPackages: Changes, addedPackages: Changes,
licenseErrors: Change[], invalidLicenseChanges: Record<string, Changes>
unknownLicenses: Change[]
): void { ): void {
core.summary core.summary
.addHeading('Dependency Review') .addHeading('Dependency Review')
.addRaw( .addRaw('We found:')
`We found ${addedPackages.length} vulnerable package(s), ${licenseErrors.length} package(s) with incompatible licenses, and ${unknownLicenses.length} package(s) with unknown licenses.` .addList([
) `${addedPackages.length} vulnerable package(s)`,
`${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.`
])
} }
export function addChangeVulnerabilitiesToSummary( export function addChangeVulnerabilitiesToSummary(
@@ -76,8 +79,7 @@ export function addChangeVulnerabilitiesToSummary(
} }
export function addLicensesToSummary( export function addLicensesToSummary(
licenseErrors: Change[], invalidLicenseChanges: Record<string, Changes>,
unknownLicenses: Change[],
config: ConfigurationOptions config: ConfigurationOptions
): void { ): void {
core.summary.addHeading('Licenses') core.summary.addHeading('Licenses')
@@ -93,62 +95,59 @@ export function addLicensesToSummary(
) )
} }
if (licenseErrors.length === 0 && unknownLicenses.length === 0) { if (Object.values(invalidLicenseChanges).every(item => item.length === 0)) {
core.summary.addQuote('No license violations detected.') core.summary.addQuote('No license violations detected.')
return return
} }
if (licenseErrors.length > 0) { core.debug(
const rows: SummaryTableRow[] = [] `found ${invalidLicenseChanges.unlicensed.length} unknown licenses`
const manifests = getManifestsSet(licenseErrors) )
core.summary.addHeading('Incompatible Licenses', 3).addSeparator() 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)
for (const manifest of manifests) { for (const manifest of manifests) {
core.summary.addHeading(`<em>${manifest}</em>`, 4) core.summary.addHeading(`<em>${manifest}</em>`, 4)
for (const change of licenseErrors.filter( for (const change of changes.filter(pkg => pkg.manifest === manifest)) {
pkg => pkg.manifest === manifest
)) {
rows.push([ rows.push([
renderUrl(change.source_repository_url, change.name), renderUrl(change.source_repository_url, change.name),
change.version, change.version,
change.license || '' formatLicense(change.license)
]) ])
} }
core.summary.addTable([['Package', 'Version', 'License'], ...rows]) core.summary.addTable([['Package', 'Version', 'License'], ...rows])
} }
} else { } else {
core.summary.addQuote('No license violations detected.') core.summary.addQuote(`No ${heading.toLowerCase()} detected.`)
} }
}
core.debug(`found ${unknownLicenses.length} unknown licenses`) function formatLicense(license: string | null): string {
if (license === null || license === 'NOASSERTION') {
if (unknownLicenses.length > 0) { return 'Null'
const rows: SummaryTableRow[] = []
const manifests = getManifestsSet(unknownLicenses)
core.debug(
`found ${manifests.entries.length} manifests for unknown licenses`
)
core.summary.addHeading('Unknown Licenses', 3).addSeparator()
for (const manifest of manifests) {
core.summary.addHeading(`<em>${manifest}</em>`, 4)
for (const change of unknownLicenses.filter(
pkg => pkg.manifest === manifest
)) {
rows.push([
renderUrl(change.source_repository_url, change.name),
change.version
])
}
core.summary.addTable([['Package', 'Version'], ...rows])
}
} }
return license
} }
export function addScannedDependencies(changes: Changes): void { export function addScannedDependencies(changes: Changes): void {
@@ -157,7 +156,7 @@ export function addScannedDependencies(changes: Changes): void {
const summary = core.summary const summary = core.summary
.addHeading('Scanned Dependencies') .addHeading('Scanned Dependencies')
.addRaw(`We scanned ${dependencies.size} manifest files:`) .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)
@@ -165,7 +164,7 @@ export function addScannedDependencies(changes: Changes): void {
const dependencyNames = deps.map( const dependencyNames = deps.map(
dependency => `<li>${dependency.name}@${dependency.version}</li>` dependency => `<li>${dependency.name}@${dependency.version}</li>`
) )
summary.addRaw(`<h3>${manifest}</h3><ul>${dependencyNames.join('')}</ul>`) summary.addDetails(manifest, `<ul>${dependencyNames.join('')}</ul>`)
} }
} }
} }
+10
View File
@@ -1,3 +1,4 @@
import spdxParse from 'spdx-expression-parse'
import {Changes} from './schemas' import {Changes} from './schemas'
export function groupDependenciesByManifest( export function groupDependenciesByManifest(
@@ -28,3 +29,12 @@ export function renderUrl(url: string | null, text: string): string {
return text return text
} }
} }
export function isSPDXValid(license: string): boolean {
try {
spdxParse(license)
return true
} catch (_) {
return false
}
}