Compare commits

..

1 Commits

Author SHA1 Message Date
Federico Builes 20f8e76960 Merge branch 'main' into add-summary
# Conflicts:
#	README.md
#	dist/index.js
#	dist/index.js.map
#	src/main.ts
2022-08-18 15:48:03 +02:00
26 changed files with 3271 additions and 27994 deletions
-9
View File
@@ -1,9 +0,0 @@
{
"name": "Dependency Review Action",
"image": "mcr.microsoft.com/devcontainers/typescript-node:18",
"postCreateCommand": "npm install",
"remoteUser": "node",
"features": {
"ghcr.io/devcontainers/features/ruby:1": {}
}
}
+2 -2
View File
@@ -23,10 +23,10 @@ jobs:
steps:
- uses: actions/checkout@v3
- name: Set Node.js 18.x
- name: Set Node.js 16.x
uses: actions/setup-node@v3
with:
node-version: 18.x
node-version: 16.x
- name: Install dependencies
run: npm ci
+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 16
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
@@ -30,7 +30,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
node-version: 16
cache: npm
- name: Install dependencies
run: npm ci --ignore-scripts
-1
View File
@@ -1,5 +1,4 @@
event.json
.ruby-version
# Dependency directory
node_modules
+17 -27
View File
@@ -1,5 +1,4 @@
# Contributing
[fork]: https://github.com/actions/dependency-review-action/fork
[pr]: https://github.com/actions/dependency-review-action/compare
[code-of-conduct]: CODE_OF_CONDUCT.md
@@ -10,6 +9,7 @@ Contributions to this project are
[released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license)
to the public under the [project's open source license](LICENSE).
Please note that this project is released with a [Contributor Code of
Conduct][code-of-conduct]. By participating in this project you agree
to abide by its terms.
@@ -20,6 +20,7 @@ This Action makes an authenticated query to the Dependency Graph Diff
API endpoint (`GET /repos/{owner}/{repo}/dependency-graph/compare/{basehead}`)
to find out the set of added and removed dependencies for each manifest.
### Bootstrapping the project
```
@@ -34,11 +35,10 @@ npm install
npm run test
```
_Note_: We don't have any useful tests yet, contributions are welcome!
*Note*: We don't have any useful tests yet, contributions are welcome!
## Local Development
It is recommended to have atleast [Node 18](https://nodejs.org/en/) installed.
We have a script to scan a given PR for vulnerabilities, this will
help you test your local changes. Make sure to [grab a Personal Access Token (PAT)](https://github.com/settings/tokens) before proceeding (you'll need `repo` permissions for private repos):
@@ -56,24 +56,16 @@ Like this:
$ GITHUB_TOKEN=my-secret-token ./scripts/scan_pr https://github.com/actions/dependency-review-action/pull/3
```
[Configuration options](README.md#configuration-options) can be set by
passing an external YAML [configuration file](README.md#configuration-file) to the
`scan_pr` script with the `-c`/`--config-file` option:
```sh
$ GITHUB_TOKEN=<token> ./scripts/scan_pr --config-file my_custom_config.yml <pr_url>
```
## Submitting a pull request
0. [Fork][fork] and clone the repository
1. Configure and install the dependencies: `npm install`
2. Make sure the tests pass on your machine: `npm run test`
3. Create a new branch: `git checkout -b my-branch-name`
4. Make your change, add tests, and make sure the tests still pass
5. Make sure to build and package before pushing: `npm run build && npm run package`
6. Push to your fork and [submit a pull request][pr]
7. Pat your self on the back and wait for your pull request to be reviewed and merged.
0. Configure and install the dependencies: `npm install`
0. Make sure the tests pass on your machine: `npm run test`
0. Create a new branch: `git checkout -b my-branch-name`
0. Make your change, add tests, and make sure the tests still pass
0. Make sure to build and package before pushing: `npm run build && npm run package`
0. Push to your fork and [submit a pull request][pr]
0. Pat your self on the back and wait for your pull request to be reviewed and merged.
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
@@ -83,23 +75,22 @@ Here are a few things you can do that will increase the likelihood of your pull
## Cutting a new release
1. Update the version number in [package.json](https://github.com/actions/dependency-review-action/blob/main/package.json).
1. Go to [Draft a new
release](https://github.com/actions/dependency-review-action/releases/new)
in the Releases page.
1. Make sure that the `Publish this Action to the GitHub Marketplace`
checkbox is enabled
release](https://github.com/actions/dependency-review-action/releases/new)
in the Releases page.
2. Make sure that the `Publish this Action to the GitHub Marketplace`
checkbox is enabled
<img width="481" alt="Screenshot 2022-06-15 at 12 08 19" src="https://user-images.githubusercontent.com/2161/173822484-4b60d8b4-c674-4bff-b5ff-b0c4a3650ab7.png">
3. Click "Choose a tag" and then "Create new tag", where the tag name
will be your version prefixed by a `v` (e.g. `v1.2.3`).
will be your version prefixed by a `v` (e.g. `v1.2.3`).
4. Use a version number for the release title (e.g. "1.2.3").
<img width="700" alt="Screenshot 2022-06-15 at 12 08 36" src="https://user-images.githubusercontent.com/2161/173822548-33ab3432-d679-4dc1-adf8-b50fdaf47de3.png">
5. Add your release notes. If this is a major version make sure to
include a small description of the biggest changes in the new version.
include a small description of the biggest changes in the new version.
6. Click "Publish Release".
You now have a tag and release using the semver version you used
@@ -109,8 +100,7 @@ major version number (e.g. `v1`) in their workflows while
automatically getting all the
minor/patch updates.
To do this just checkout `main`, force-create a new annotated tag, and push it:
To do this just force-create a new annotated tag and push it:
```
git tag -fa v2 -m "Updating v2 to 2.3.4"
git push origin v2 --force
+29 -174
View File
@@ -38,7 +38,7 @@ jobs:
### GitHub Enterprise Server
This action is available in Enterprise Server starting with version 3.6. Make sure
This action is available in GHES starting with version 3.6. Make sure
[GitHub Advanced
Security](https://docs.github.com/en/enterprise-server@3.6/admin/code-security/managing-github-advanced-security-for-your-enterprise/enabling-github-advanced-security-for-your-enterprise)
and [GitHub
@@ -50,6 +50,7 @@ with the label of any of your runners (the default label
is `self-hosted`):
```yaml
# ...
jobs:
@@ -64,158 +65,9 @@ jobs:
## Configuration
Configure this action by either using an external configuration file,
or by inlining these options in your workflow file.
## Configuration Options
### config-file
A string representing the path to an external configuration file. By
default external configuration files are not used.
**Possible values**: A string representing the absolute path to the
configuration file.
**Example**: `config-file: ./.github/dependency-review-config.yml`.
### fail-on-severity
Configure the severity level for alerting. See "[Vulnerability Severity](https://github.com/actions/dependency-review-action#vulnerability-severity)".
**Possible values**: `critical`, `high`, `moderate`, `low`.
**Example**: `fail-on-severity: moderate`.
### fail-on-scopes
A list of strings representing the build environments you want to
support. The default value is `development, runtime`.
**Possible values**: `development`, `runtime`, `unknown`
**Inline example**: `fail-on-scopes: development, runtime`
**YAML example**:
```yaml
# this prevents scanning development dependencies
fail-on-scopes:
- runtime
```
### allow-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**: A list of of [SPDX-compliant license identifiers](https://spdx.org/licenses/).
**Inline example**: `allow-licenses: BSD-3-Clause, LGPL-2.1 OR MIT OR BSD-3-Clause`
**YAML example**:
```yaml
allow-licenses:
- BSD-3-Clause
- LGPL-2.1
- MIT
- BSD-3-Clause
```
### deny-licenses
Add a custom list of licenses you want to block. See
"[Licenses](https://github.com/actions/dependency-review-action#licenses)".
**Possible values**: Any valid set of [SPDX licenses](https://spdx.org/licenses/).
**Inline example**: `deny-licenses: LGPL-2.0, GPL-2.0+ WITH Bison-exception-2.2`
**YAML example**:
```yaml
deny-licenses:
- LGPL-2.0
- GPL-2.0+ WITH Bison-exception-2.2
```
### allow-ghsas
Add a custom list of GitHub Advisory IDs that can be skipped during detection.
**Possible values**: Any valid advisory GHSA ids.
**Inline example**: `allow-ghsas: GHSA-abcd-1234-5679, GHSA-efgh-1234-5679`
**YAML example**:
```yaml
allow-ghsas:
- GHSA-abcd-1234-5679
- GHSA-efgh-1234-5679
```
### license-check/vulnerability-check
Disable the license checks or vulnerability checks performed by this Action.
You can't disable both checks.
**Possible values**: `true` or `false`
**Example**:
```yaml
license-check: true
vulnerability-check: false
```
### base-ref/head-ref
Provide custom git references for the git base/head when performing
the comparison. If you are using pull requests, or
`pull_request_target` events you do not need to worry about setting
this. The values need to be specified for all other event types.
**Possible values**: Any valid git ref(s) in your project.
**Example**:
```yaml
base-ref: 8bb8a58d6a4028b6c2e314d5caaf273f57644896
head-ref: 69af5638bf660cf218aad5709a4c100e42a2f37b
```
### Configuration File
You can use an external configuration file to specify the settings for
this Action.
Start by specifying that you will be using an external configuration
file:
```yaml
- name: Dependency Review
uses: actions/dependency-review-action@v2
with:
config-file: './.github/dependency-review-config.yml'
```
And then create the file in the path you just specified. **All of these fields are
optional**:
```yaml
fail-on-severity: 'critical'
allow-licenses:
- 'GPL-3.0'
- 'BSD-3-Clause'
- 'MIT'
```
### Inline Configuration
You can pass options to the Dependency Review
Action using your workflow file. Here's an example of what the full
file would look like:
You can pass additional options to the Dependency Review
Action using your workflow file. Here's an example workflow with
all the possible configurations:
```yaml
name: 'Dependency Review'
@@ -231,12 +83,27 @@ jobs:
- name: Dependency Review
uses: actions/dependency-review-action@v2
with:
fail-on-severity: moderate
# Use comma-separated names to pass list arguments:
deny-licenses: LGPL-2.0, BSD-2-Clause
# Possible values: "critical", "high", "moderate", "low"
# fail-on-severity: critical
#
# Possible values: Any available git ref
# base-ref: ${{ github.event.pull_request.base.ref }}
# head-ref: ${{ github.event.pull_request.head.ref }}
#
# You can only include one of these two options: `allow-licenses` and `deny-licenses`. These options are not supported on GHES.
#
# Possible values: Any `spdx_id` value(s) from https://docs.github.com/en/rest/licenses
# allow-licenses: GPL-3.0, BSD-3-Clause, MIT
#
# Possible values: Any `spdx_id` value(s) from https://docs.github.com/en/rest/licenses
# deny-licenses: LGPL-2.0, BSD-2-Clause
```
When the workflow with this action is caused by a `pull_request` or `pull_request_target` event,
the `base-ref` and `head-ref` values have the defaults as shown above. If the workflow is caused by
any other event, the `base-ref` and `head-ref` options must be
explicitly set in the configuration file.
### Vulnerability Severity
By default the action will fail on any pull request that contains a
@@ -253,28 +120,17 @@ This example will only fail on pull requests with `critical` and `high` vulnerab
fail-on-severity: high
```
### Dependency Scoping
By default the action will only fail on `runtime` dependencies that have vulnerabilities or unacceptable licenses, ignoring `development` dependencies. You can override this behavior with the `fail-on-scopes` option, which will allow you to list the specific dependency scopes you care about. The possible values are: `unknown`, `runtime`, and `development`. Note: Filtering by scope will not be supported on Enterprise Server just yet, as the REST API's introduction of `scope` will be released in an upcoming Enterprise Server version. We will treat all dependencies on Enterprise Server as having a `runtime` scope and thus will not be filtered away.
```yaml
- name: Dependency Review
uses: actions/dependency-review-action@v2
with:
fail-on-scopes: runtime, development
```
### Licenses
You can set the action to fail on pull requests based on the licenses of the dependencies
they introduce. With `allow-licenses` you can define the list of licenses
your repository will accept. Alternatively, you can use `deny-licenses` to only
forbid a subset of licenses. These options are not supported on Enterprise Server.
forbid a subset of licenses. These options are not supported on GHES.
You can use the [Licenses
API](https://docs.github.com/en/rest/licenses) to see the full list of
supported licenses. Use [SPDX licenses](https://spdx.org/licenses/)
to filter the licenses. A couple of examples:
supported licenses. Use the `spdx_id` field for every license you want
to filter. A couple of examples:
```yaml
# only allow MIT-licensed dependents
@@ -289,12 +145,11 @@ to filter the licenses. A couple of examples:
- name: Dependency Review
uses: actions/dependency-review-action@v2
with:
deny-licenses: Apache-1.1+
deny-licenses: Apache-1.1, Apache-2.0
```
### Considerations
**Important**
- Checking for licenses is not supported on Enterprise Server.
- The action will only accept one of the two parameters; an error will
be raised if you provide both.
- By default both parameters are empty (no license checking is
+1 -161
View File
@@ -1,7 +1,6 @@
import {expect, test, beforeEach} from '@jest/globals'
import {readConfig, readConfigFile} from '../src/config'
import {readConfig} from '../src/config'
import {getRefs} from '../src/git-refs'
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)
@@ -14,13 +13,8 @@ function setInput(input: string, value: string) {
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'
]
@@ -30,10 +24,6 @@ function clearInputs() {
})
}
beforeAll(() => {
jest.spyOn(Utils, 'isSPDXValid').mockReturnValue(true)
})
beforeEach(() => {
clearInputs()
})
@@ -92,153 +82,3 @@ test('it raises an error when no refs are provided and the event is not a pull r
})
).toThrow()
})
test('it reads an external config file', async () => {
let options = readConfigFile('./__tests__/fixtures/config-allow-sample.yml')
expect(options.fail_on_severity).toEqual('critical')
expect(options.allow_licenses).toEqual(['BSD', 'GPL 2'])
})
test('raises an error when the the config file was not found', async () => {
expect(() => readConfigFile('fixtures/i-dont-exist')).toThrow()
})
test('it parses options from both sources', async () => {
setInput('config-file', './__tests__/fixtures/config-allow-sample.yml')
let options = readConfig()
expect(options.fail_on_severity).toEqual('critical')
setInput('base-ref', 'a-custom-base-ref')
options = readConfig()
expect(options.base_ref).toEqual('a-custom-base-ref')
})
test('in case of conflicts, the external config is the source of truth', async () => {
setInput('config-file', './__tests__/fixtures/config-allow-sample.yml') // this will set fail-on-severity to 'critical'
let options = readConfig()
expect(options.fail_on_severity).toEqual('critical')
// this should not overwite the previous value
setInput('fail-on-severity', 'low')
options = readConfig()
expect(options.fail_on_severity).toEqual('critical')
})
test('it uses the default values when loading external files', async () => {
setInput('config-file', './__tests__/fixtures/no-licenses-config.yml')
let options = readConfig()
expect(options.allow_licenses).toEqual(undefined)
expect(options.deny_licenses).toEqual(undefined)
setInput('config-file', './__tests__/fixtures/license-config-sample.yml')
options = readConfig()
expect(options.fail_on_severity).toEqual('low')
})
test('it accepts an external configuration filename', async () => {
setInput('config-file', './__tests__/fixtures/no-licenses-config.yml')
const options = readConfig()
expect(options.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')
expect(() => readConfig()).toThrow()
})
test('it defaults to runtime scope', async () => {
const options = readConfig()
expect(options.fail_on_scopes).toEqual(['runtime'])
})
test('it parses custom scopes preference', async () => {
setInput('fail-on-scopes', 'runtime, development')
let options = readConfig()
expect(options.fail_on_scopes).toEqual(['runtime', 'development'])
clearInputs()
setInput('fail-on-scopes', 'development')
options = readConfig()
expect(options.fail_on_scopes).toEqual(['development'])
})
test('it raises an error when given invalid scope', async () => {
setInput('fail-on-scopes', 'runtime, zombies')
expect(() => readConfig()).toThrow()
})
test('it defaults to an empty GHSA allowlist', async () => {
const options = readConfig()
expect(options.allow_ghsas).toEqual(undefined)
})
test('it successfully parses GHSA allowlist', async () => {
setInput('allow-ghsas', 'GHSA-abcd-1234-5679, GHSA-efgh-1234-5679')
const options = readConfig()
expect(options.allow_ghsas).toEqual([
'GHSA-abcd-1234-5679',
'GHSA-efgh-1234-5679'
])
})
test('it defaults to checking licenses', async () => {
const options = readConfig()
expect(options.license_check).toBe(true)
})
test('it parses the license-check input', async () => {
setInput('license-check', 'false')
let options = readConfig()
expect(options.license_check).toEqual(false)
clearInputs()
setInput('license-check', 'true')
options = readConfig()
expect(options.license_check).toEqual(true)
})
test('it defaults to checking vulnerabilities', async () => {
const options = readConfig()
expect(options.vulnerability_check).toBe(true)
})
test('it parses the vulnerability-check input', async () => {
setInput('vulnerability-check', 'false')
let options = readConfig()
expect(options.vulnerability_check).toEqual(false)
clearInputs()
setInput('vulnerability-check', 'true')
options = readConfig()
expect(options.vulnerability_check).toEqual(true)
})
test('it is not possible to disable both checks', async () => {
setInput('license-check', 'false')
setInput('vulnerability-check', 'false')
expect(() => {
readConfig()
}).toThrow("Can't disable both license-check and vulnerability-check")
})
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')
})
})
+1 -65
View File
@@ -1,10 +1,6 @@
import {expect, test} from '@jest/globals'
import {Change, Changes} from '../src/schemas'
import {
filterChangesBySeverity,
filterChangesByScopes,
filterAllowedAdvisories
} from '../src/filter'
import {filterChangesBySeverity} from '../src/filter'
let npmChange: Change = {
manifest: 'package.json',
@@ -15,7 +11,6 @@ let npmChange: Change = {
package_url: 'pkg:npm/reeuhq@1.0.2',
license: 'MIT',
source_repository_url: 'github.com/some-repo',
scope: 'runtime',
vulnerabilities: [
{
severity: 'critical',
@@ -35,7 +30,6 @@ let rubyChange: Change = {
package_url: 'pkg:gem/actionsomething@3.2.0',
license: 'BSD',
source_repository_url: 'github.com/some-repo',
scope: 'development',
vulnerabilities: [
{
severity: 'moderate',
@@ -52,19 +46,6 @@ let rubyChange: Change = {
]
}
let noVulnNpmChange: Change = {
manifest: 'package.json',
change_type: 'added',
ecosystem: 'npm',
name: 'helpful',
version: '1.0.0',
package_url: 'pkg:npm/helpful@1.0.0',
license: 'MIT',
source_repository_url: 'github.com/some-repo',
scope: 'runtime',
vulnerabilities: []
}
test('it properly filters changes by severity', async () => {
const changes = [npmChange, rubyChange]
let result = filterChangesBySeverity('high', changes)
@@ -76,48 +57,3 @@ test('it properly filters changes by severity', async () => {
result = filterChangesBySeverity('critical', changes)
expect(changes).toEqual([npmChange, rubyChange])
})
test('it properly filters changes by scope', async () => {
const changes = [npmChange, rubyChange]
let result = filterChangesByScopes(['runtime'], changes)
expect(result).toEqual([npmChange])
result = filterChangesByScopes(['development'], changes)
expect(result).toEqual([rubyChange])
result = filterChangesByScopes(['runtime', 'development'], changes)
expect(result).toEqual([npmChange, rubyChange])
})
test('it properly handles undefined advisory IDs', async () => {
const changes = [npmChange, rubyChange, noVulnNpmChange]
let result = filterAllowedAdvisories(undefined, changes)
expect(result).toEqual([npmChange, rubyChange, noVulnNpmChange])
})
test('it properly filters changes with allowed vulnerabilities', async () => {
const changes = [npmChange, rubyChange, noVulnNpmChange]
let result = filterAllowedAdvisories(['notrealGHSAID'], changes)
expect(result).toEqual([npmChange, rubyChange, noVulnNpmChange])
result = filterAllowedAdvisories(['first-random_string'], changes)
expect(result).toEqual([rubyChange, noVulnNpmChange])
result = filterAllowedAdvisories(
['second-random_string', 'third-random_string'],
changes
)
expect(result).toEqual([npmChange, noVulnNpmChange])
result = filterAllowedAdvisories(
['first-random_string', 'second-random_string', 'third-random_string'],
changes
)
expect(result).toEqual([noVulnNpmChange])
// if we have a change with multiple vulnerabilities but only one is allowed, we still should not filter out that change
result = filterAllowedAdvisories(['second-random_string'], changes)
expect(result).toEqual([npmChange, rubyChange, noVulnNpmChange])
})
@@ -1,3 +0,0 @@
fail-on-severity: 'so many zombies'
deny-licenses:
- MIT
@@ -1 +0,0 @@
allow_licenses: ['MIT', 'GPL 2']
+20 -123
View File
@@ -1,7 +1,6 @@
import {expect, jest, test} from '@jest/globals'
import {expect, test} from '@jest/globals'
import {Change, Changes} from '../src/schemas'
let getInvalidLicenseChanges: Function
import {getDeniedLicenseChanges} from '../src/licenses'
let npmChange: Change = {
manifest: 'package.json',
@@ -12,7 +11,6 @@ let npmChange: Change = {
package_url: 'pkg:npm/reeuhq@1.0.2',
license: 'MIT',
source_repository_url: 'github.com/some-repo',
scope: 'runtime',
vulnerabilities: [
{
severity: 'critical',
@@ -32,7 +30,6 @@ let rubyChange: Change = {
package_url: 'pkg:gem/actionsomething@3.2.0',
license: 'BSD',
source_repository_url: 'github.com/some-repo',
scope: 'runtime',
vulnerabilities: [
{
severity: 'moderate',
@@ -49,153 +46,53 @@ let rubyChange: Change = {
]
}
jest.mock('@actions/core')
const mockOctokit = {
rest: {
licenses: {
getForRepo: jest
.fn()
.mockReturnValue({data: {license: {spdx_id: 'AGPL'}}})
}
}
}
jest.mock('octokit', () => {
return {
Octokit: class {
constructor() {
return mockOctokit
}
}
}
})
beforeEach(async () => {
jest.resetModules()
jest.doMock('spdx-satisfies', () => {
// mock spdx-satisfies return value
// true for BSD, false for all others
return jest.fn((license: string, _: string): boolean => license === 'BSD')
})
;({getInvalidLicenseChanges} = require('../src/licenses'))
})
test('it adds license outside the allow list to forbidden changes', async () => {
test('it fails if a license outside the allow list is found', async () => {
const changes: Changes = [npmChange, rubyChange]
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['BSD']
})
expect(forbidden[0]).toBe(npmChange)
expect(forbidden.length).toEqual(1)
const [invalidChanges, _] = getDeniedLicenseChanges(changes, {allow: ['BSD']})
expect(invalidChanges[0]).toBe(npmChange)
})
test('it adds license inside the deny list to forbidden changes', async () => {
test('it fails if a license inside the deny list is found', async () => {
const changes: Changes = [npmChange, rubyChange]
const {forbidden} = await getInvalidLicenseChanges(changes, {
deny: ['BSD']
})
expect(forbidden[0]).toBe(rubyChange)
expect(forbidden.length).toEqual(1)
const [invalidChanges] = getDeniedLicenseChanges(changes, {deny: ['BSD']})
expect(invalidChanges[0]).toBe(rubyChange)
})
// 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.
test('it adds all licenses to forbidden changes when allow is provided an empty array', async () => {
test('it fails all license checks when allow is provided an empty array', async () => {
const changes: Changes = [npmChange, rubyChange]
let {forbidden} = await getInvalidLicenseChanges(changes, {
let [invalidChanges, _] = getDeniedLicenseChanges(changes, {
allow: [],
deny: ['BSD']
})
expect(forbidden.length).toBe(2)
expect(invalidChanges.length).toBe(2)
})
test('it does not add license outside the allow list to forbidden changes if it is in removed changes', async () => {
test('it does not fail if a license outside the allow list is found in removed changes', async () => {
const changes: Changes = [
{...npmChange, change_type: 'removed'},
{...rubyChange, change_type: 'removed'}
]
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['BSD']
})
expect(forbidden).toStrictEqual([])
const [invalidChanges, _] = getDeniedLicenseChanges(changes, {allow: ['BSD']})
expect(invalidChanges).toStrictEqual([])
})
test('it does not add license inside the deny list to forbidden changes if it is in removed changes', async () => {
test('it does not fail if a license inside the deny list is found in removed changes', async () => {
const changes: Changes = [
{...npmChange, change_type: 'removed'},
{...rubyChange, change_type: 'removed'}
]
const {forbidden} = await getInvalidLicenseChanges(changes, {
deny: ['BSD']
})
expect(forbidden).toStrictEqual([])
const [invalidChanges, _] = getDeniedLicenseChanges(changes, {deny: ['BSD']})
expect(invalidChanges).toStrictEqual([])
})
test('it adds license outside the allow list to forbidden changes if it is in both added and removed changes', async () => {
test('it fails if a license outside the allow list is found in both of added and removed changes', async () => {
const changes: Changes = [
{...npmChange, change_type: 'removed'},
npmChange,
{...rubyChange, change_type: 'removed'}
]
const {forbidden} = await getInvalidLicenseChanges(changes, {
allow: ['BSD']
})
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', () => {
test('it calls licenses endpoint if atleast one of the changes has null license and valid source_repository_url', async () => {
const nullLicenseChange = {
...npmChange,
license: null,
source_repository_url: 'http://github.com/some-owner/some-repo'
}
const {unlicensed} = await getInvalidLicenseChanges(
[nullLicenseChange, rubyChange],
{}
)
expect(mockOctokit.rest.licenses.getForRepo).toHaveBeenNthCalledWith(1, {
owner: 'some-owner',
repo: 'some-repo'
})
expect(unlicensed.length).toEqual(0)
})
test('it does not call licenses API endpoint for change with null license and invalid source_repository_url ', async () => {
const {unlicensed} = await getInvalidLicenseChanges(
[{...npmChange, license: null}],
{}
)
expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled()
expect(unlicensed.length).toEqual(1)
})
test('it does not call licenses API endpoint if licenses for all changes are present', async () => {
const {unlicensed} = await getInvalidLicenseChanges(
[npmChange, rubyChange],
{}
)
expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled()
expect(unlicensed.length).toEqual(0)
})
const [invalidChanges, _] = getDeniedLicenseChanges(changes, {allow: ['BSD']})
expect(invalidChanges).toStrictEqual([npmChange])
})
-10
View File
@@ -10,28 +10,18 @@ inputs:
description: Don't block PRs below this severity. Possible values are `low`, `moderate`, `high`, `critical`.
required: false
default: 'low'
fail-on-scopes:
description: Dependency scopes to block PRs on. Comma-separated list. Possible values are 'unknown', 'runtime', and 'development' (e.g. "runtime, development")
required: false
default: 'runtime'
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.
required: false
head-ref:
description: The head 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
config-file:
description: A filepath to the configuration file for the action.
required: false
allow-licenses:
description: Comma-separated list of allowed licenses (e.g. "MIT, GPL 3.0, BSD 2 Clause")
required: false
deny-licenses:
description: Comma-separated list of forbidden licenses (e.g. "MIT, GPL 3.0, BSD 2 Clause")
required: false
allow-ghsas:
description: Comma-separated list of allowed Github Advisory IDs (e.g. "GHSA-abcd-1234-5679, GHSA-efgh-1234-5679")
required: false
runs:
using: 'node16'
main: 'dist/index.js'
Generated Vendored
+585 -21646
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
-1174
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,9 +1,9 @@
module.exports = {
clearMocks: true,
moduleFileExtensions: ['js', 'json', 'ts'],
moduleFileExtensions: ['js', 'ts'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.ts$': 'ts-jest'
},
verbose: true
}
}
+2442 -3953
View File
File diff suppressed because it is too large Load Diff
+18 -24
View File
@@ -1,6 +1,6 @@
{
"name": "dependency-review-action",
"version": "2.5.1",
"version": "2.0.4",
"private": true,
"description": "A GitHub Action for Dependency Review",
"main": "lib/main.js",
@@ -25,36 +25,30 @@
"author": "GitHub",
"license": "MIT",
"dependencies": {
"@actions/core": "^1.10.0",
"@actions/github": "^5.1.1",
"@octokit/plugin-retry": "^4.0.3",
"@octokit/request-error": "^3.0.2",
"ansi-styles": "^6.2.1",
"got": "^12.5.2",
"nodemon": "^2.0.20",
"octokit": "^2.0.10",
"spdx-expression-parse": "^3.0.1",
"spdx-satisfies": "^5.0.1",
"yaml": "^2.1.3",
"zod": "^3.19.1"
"@actions/core": "^1.9.1",
"@actions/github": "^5.0.3",
"@octokit/plugin-retry": "^3.0.9",
"@octokit/request-error": "^3.0.1",
"ansi-styles": "^6.1.0",
"got": "^12.3.1",
"nodemon": "^2.0.19",
"yaml": "^2.1.1",
"zod": "^3.18.0"
},
"devDependencies": {
"@types/jest": "^27.5.2",
"@types/node": "^16.18.2",
"@typescript-eslint/eslint-plugin": "^5.41.0",
"@typescript-eslint/parser": "^5.41.0",
"@types/spdx-expression-parse": "^3.0.2",
"@types/spdx-satisfies": "^0.1.0",
"@types/node": "^16.11.49",
"@typescript-eslint/eslint-plugin": "^5.33.1",
"@typescript-eslint/parser": "^5.33.1",
"@vercel/ncc": "^0.34.0",
"esbuild-register": "^3.3.3",
"eslint": "^8.26.0",
"eslint-plugin-github": "^4.4.0",
"eslint-plugin-jest": "^27.1.3",
"eslint": "^8.22.0",
"eslint-plugin-github": "^4.3.7",
"eslint-plugin-jest": "^26.8.3",
"jest": "^27.5.1",
"js-yaml": "^4.1.0",
"nodemon": "^2.0.20",
"nodemon": "^2.0.19",
"prettier": "2.7.1",
"ts-jest": "^27.1.4",
"typescript": "^4.8.4"
"typescript": "^4.7.4"
}
}
+6 -45
View File
@@ -3,52 +3,22 @@ require 'json'
require 'tempfile'
require 'open3'
require 'bundler/inline'
require 'optparse'
gemfile do
source 'https://rubygems.org'
gem 'octokit'
end
config_file = nil
github_token = ENV["GITHUB_TOKEN"]
if !github_token || github_token.empty?
puts "Please set the GITHUB_TOKEN environment variable"
exit -1
end
op = OptionParser.new do |opts|
usage = <<EOF
Run Dependency Review on a repository.
\e[1mUsage:\e[22m
scripts/scan_pr [options] <pr_url>
\e[1mExample:\e[22m
scripts/scan_pr https://github.com/actions/dependency-review-action/pull/294
EOF
opts.banner = usage
opts.on('-c', '--config-file <FILE>', 'Use an external configuration file') do |cf|
config_file = cf
end
opts.on("-h", "--help", "Prints this help") do
puts opts
exit
end
end
op.parse!
# make sure we have a NWO somewhere in the parameters
arg = /(?<repo_nwo>[\w\-]+\/[\w\-]+)\/pull\/(?<pr_number>\d+)/.match(ARGV.join(" "))
arg = /(?<repo_nwo>[\w\-]+\/[\w\-]+)\/pull\/(?<pr_number>\d+)/.match(ARGV[0])
if arg.nil?
puts op
puts "Usage: script/scan_pr <pr_url>"
exit -1
end
@@ -62,26 +32,17 @@ event_file = Tempfile.new
event_file.write("{ \"pull_request\": #{pr.to_h.to_json}}")
event_file.close
action_inputs = {
"repo-token": github_token,
"config-file": config_file
}
dev_cmd_env = {
"INPUT_REPO-TOKEN" => github_token,
"GITHUB_REPOSITORY" => repo_nwo,
"GITHUB_EVENT_NAME" => "pull_request",
"GITHUB_EVENT_PATH" => event_file.path,
"GITHUB_STEP_SUMMARY" => "/dev/null"
"GITHUB_EVENT_PATH" => event_file.path
}
# bash does not like variable names with dashes like the ones Actions
# uses (e.g. INPUT_REPO-TOKEN). Passing them through `env` instead of
# manually setting them does the job.
action_inputs_env_str = action_inputs.map { |name, value| "\"INPUT_#{name.upcase}=#{value}\"" }.join(" ")
dev_cmd = "./node_modules/.bin/nodemon --exec \"env #{action_inputs_env_str} node -r esbuild-register\" src/main.ts"
dev_cmd = "./node_modules/.bin/nodemon --exec \"node -r esbuild-register\" src/main.ts"
Open3.popen2e(dev_cmd_env, dev_cmd) do |stdin, out|
while line = out.gets
puts line.gsub(github_token, "<REDACTED>")
puts line
end
end
+9 -112
View File
@@ -1,135 +1,32 @@
import * as fs from 'fs'
import path from 'path'
import YAML from 'yaml'
import * as core from '@actions/core'
import * as z from 'zod'
import {
ConfigurationOptions,
ConfigurationOptionsSchema,
SeveritySchema,
SCOPES
} from './schemas'
import {isSPDXValid} from './utils'
type licenseKey = 'allow-licenses' | 'deny-licenses'
function getOptionalBoolean(name: string): boolean | undefined {
const value = core.getInput(name)
return value.length > 0 ? core.getBooleanInput(name) : undefined
}
import {ConfigurationOptions, SEVERITIES} from './schemas'
function getOptionalInput(name: string): string | undefined {
const value = core.getInput(name)
return value.length > 0 ? value : undefined
}
function parseList(list: string | undefined): string[] | undefined {
if (list === undefined) {
return list
} else {
return list.split(',').map(x => x.trim())
}
}
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 {
const externalConfig = getOptionalInput('config-file')
if (externalConfig !== undefined) {
const config = readConfigFile(externalConfig)
// the reasoning behind reading the inline config when an external
// config file is provided is that we still want to allow users to
// pass inline options in the presence of an external config file.
const inlineConfig = readInlineConfig()
// the external config takes precedence
return Object.assign({}, inlineConfig, config)
} else {
return readInlineConfig()
}
}
export function readInlineConfig(): ConfigurationOptions {
const fail_on_severity = SeveritySchema.parse(
getOptionalInput('fail-on-severity')
)
const fail_on_scopes = z
.array(z.enum(SCOPES))
.default(['runtime'])
.parse(parseList(getOptionalInput('fail-on-scopes')))
const allow_licenses = parseList(getOptionalInput('allow-licenses'))
const deny_licenses = parseList(getOptionalInput('deny-licenses'))
const fail_on_severity = z
.enum(SEVERITIES)
.default('low')
.parse(getOptionalInput('fail-on-severity'))
const allow_licenses = getOptionalInput('allow-licenses')
const deny_licenses = getOptionalInput('deny-licenses')
if (allow_licenses !== undefined && deny_licenses !== undefined) {
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 license_check = z
.boolean()
.default(true)
.parse(getOptionalBoolean('license-check'))
const vulnerability_check = z
.boolean()
.default(true)
.parse(getOptionalBoolean('vulnerability-check'))
if (license_check === false && vulnerability_check === false) {
throw new Error("Can't disable both license-check and vulnerability-check")
}
const base_ref = getOptionalInput('base-ref')
const head_ref = getOptionalInput('head-ref')
return {
fail_on_severity,
fail_on_scopes,
allow_licenses,
deny_licenses,
allow_ghsas,
license_check,
vulnerability_check,
allow_licenses: allow_licenses?.split(',').map(x => x.trim()),
deny_licenses: deny_licenses?.split(',').map(x => x.trim()),
base_ref,
head_ref
}
}
export function readConfigFile(filePath: string): ConfigurationOptions {
let data
try {
data = fs.readFileSync(path.resolve(filePath), 'utf-8')
} catch (error: unknown) {
throw error
}
data = YAML.parse(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('-')) {
data[key.replace(/-/g, '_')] = data[key]
delete data[key]
}
}
const values = ConfigurationOptionsSchema.parse(data)
return values
}
+1 -58
View File
@@ -1,4 +1,4 @@
import {Changes, Severity, SEVERITIES, Scope} from './schemas'
import {Changes, Severity, SEVERITIES} from './schemas'
export function filterChangesBySeverity(
severity: Severity,
@@ -33,60 +33,3 @@ export function filterChangesBySeverity(
)
return filteredChanges
}
export function filterChangesByScopes(
scopes: Scope[] | undefined,
changes: Changes
): Changes {
if (scopes === undefined) {
return []
}
const filteredChanges = changes.filter(change => {
// if there is no scope on the change (Enterprise Server API for now), we will assume it is a runtime scope
const scope = change.scope || 'runtime'
return scopes.includes(scope)
})
return filteredChanges
}
/**
* Filter out changes that are allowed by the allow_ghsas config
* option. We want to remove these changes before we do any
* processing.
* @param ghsas - list of GHSA IDs to allow
* @param changes - list of changes to filter
* @returns a list of changes with the allowed GHSAs removed
*/
export function filterAllowedAdvisories(
ghsas: string[] | undefined,
changes: Changes
): Changes {
if (ghsas === undefined) {
return changes
}
const filteredChanges = changes.filter(change => {
const noAdvisories =
change.vulnerabilities === undefined ||
change.vulnerabilities.length === 0
if (noAdvisories) {
return true
}
let allAllowedAdvisories = true
// if there's at least one advisory that is not allowlisted, we will keep the change
for (const vulnerability of change.vulnerabilities) {
if (!ghsas.includes(vulnerability.advisory_ghsa_id)) {
allAllowedAdvisories = false
}
if (!allAllowedAdvisories) {
return true
}
}
})
return filteredChanges
}
+18 -146
View File
@@ -1,8 +1,4 @@
import * as core from '@actions/core'
import spdxSatisfies from 'spdx-satisfies'
import {Octokit} from 'octokit'
import {Change, Changes} from './schemas'
import {isSPDXValid} from './utils'
import {Change} from './schemas'
/**
* Loops through a list of changes, filtering and returning the
@@ -14,164 +10,40 @@ import {isSPDXValid} from './utils'
* 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
* @returns {[Array<Change>, Array<Change]} A tuple where the first element is the list of denied changes and the second one is the list of changes with unknown licenses
*/
export async function getInvalidLicenseChanges(
export function getDeniedLicenseChanges(
changes: Change[],
licenses: {
allow?: string[]
deny?: string[]
}
): Promise<Record<string, Changes>> {
): [Change[], Change[]] {
const {allow, deny} = licenses
const groupedChanges = await groupChanges(changes)
const licensedChanges: Changes = groupedChanges.licensed
const invalidLicenseChanges: Record<string, Changes> = {
unlicensed: groupedChanges.unlicensed,
unresolved: [],
forbidden: []
}
const validityCache = new Map<string, boolean>()
for (const change of licensedChanges) {
const license = change.license
// should never happen since licensedChanges always have licenses but license is nullable in changes schema
if (license === null) {
continue
}
if (license === 'NOASSERTION') {
invalidLicenseChanges.unlicensed.push(change)
} else if (validityCache.get(license) === undefined) {
try {
if (allow !== undefined) {
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 invalidLicenseChanges
}
const fetchGHLicense = async (
owner: string,
repo: string
): Promise<string | null> => {
const octokit = new Octokit({
auth: core.getInput('repo-token', {required: true})
})
try {
const response = await octokit.rest.licenses.getForRepo({owner, repo})
return response.data.license?.spdx_id ?? null
} catch (_) {
return null
}
}
const parseGitHubURL = (url: string): {owner: string; repo: string} | null => {
try {
const parsed = new URL(url)
if (parsed.host !== 'github.com') {
return null
}
const components = parsed.pathname.split('/')
if (components.length < 3) {
return null
}
return {owner: components[1], repo: components[2]}
} catch (_) {
return null
}
}
const setGHLicenses = async (changes: Change[]): Promise<Change[]> => {
const updatedChanges = changes.map(async change => {
if (change.license !== null || change.source_repository_url === null) {
return change
}
const githubUrl = parseGitHubURL(change.source_repository_url)
if (githubUrl === null) {
return change
}
return {
...change,
license: await fetchGHLicense(githubUrl.owner, githubUrl.repo)
}
})
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 = []
const disallowed: Change[] = []
const unknown: Change[] = []
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)
const license = change.license
if (license === null) {
unknown.push(change)
continue
}
if (allow !== undefined) {
if (!allow.includes(license)) {
disallowed.push(change)
}
} else {
if (
truncatedDGLicense(change.license) &&
change.source_repository_url !== null
) {
ghChanges.push(change)
} else {
result.licensed.push(change)
} else if (deny !== undefined) {
if (deny.includes(license)) {
disallowed.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
return [disallowed, unknown]
}
+60 -135
View File
@@ -3,19 +3,13 @@ import * as dependencyGraph from './dependency-graph'
import * as github from '@actions/github'
import styles from 'ansi-styles'
import {RequestError} from '@octokit/request-error'
import {Change, Severity, Changes} from './schemas'
import {Change, Severity} from './schemas'
import {readConfig} from '../src/config'
import {
filterChangesBySeverity,
filterChangesByScopes,
filterAllowedAdvisories
} from '../src/filter'
import {getInvalidLicenseChanges} from './licenses'
import {filterChangesBySeverity} from '../src/filter'
import {getDeniedLicenseChanges} from './licenses'
import * as summary from './summary'
import {getRefs} from './git-refs'
import {groupDependenciesByManifest} from './utils'
async function run(): Promise<void> {
try {
const config = readConfig()
@@ -28,16 +22,17 @@ async function run(): Promise<void> {
headRef: refs.head
})
const minSeverity = config.fail_on_severity as Severity
const scopedChanges = filterChangesByScopes(config.fail_on_scopes, changes)
const filteredChanges = filterAllowedAdvisories(
config.allow_ghsas,
scopedChanges
)
const minSeverity = config.fail_on_severity
let failed = false
const licenses = {
allow: config.allow_licenses,
deny: config.deny_licenses
}
const addedChanges = filterChangesBySeverity(
minSeverity,
filteredChanges
minSeverity as Severity,
changes
).filter(
change =>
change.change_type === 'added' &&
@@ -45,30 +40,38 @@ async function run(): Promise<void> {
change.vulnerabilities.length > 0
)
const invalidLicenseChanges = await getInvalidLicenseChanges(
filteredChanges,
{
allow: config.allow_licenses,
deny: config.deny_licenses
const [licenseErrors, unknownLicenses] = getDeniedLicenseChanges(
changes,
licenses
)
summary.addSummaryToSummary(addedChanges, licenseErrors, unknownLicenses)
if (addedChanges.length > 0) {
for (const change of addedChanges) {
printChangeVulnerabilities(change)
}
)
summary.addSummaryToSummary(
config.vulnerability_check ? addedChanges : null,
config.license_check ? invalidLicenseChanges : null
)
if (config.vulnerability_check) {
summary.addChangeVulnerabilitiesToSummary(addedChanges, minSeverity)
printVulnerabilitiesBlock(addedChanges, minSeverity)
}
if (config.license_check) {
summary.addLicensesToSummary(invalidLicenseChanges, config)
printLicensesBlock(invalidLicenseChanges)
failed = true
}
summary.addScannedDependencies(changes)
printScannedDependencies(changes)
summary.addChangeVulnerabilitiesToSummary(addedChanges, minSeverity || '')
if (licenseErrors.length > 0) {
printLicensesError(licenseErrors)
core.setFailed('Dependency review detected incompatible licenses.')
}
printNullLicenses(unknownLicenses)
summary.addLicensesToSummary(licenseErrors, unknownLicenses, config)
if (failed) {
core.setFailed('Dependency review detected vulnerable packages.')
} else {
core.info(
`Dependency review did not detect any vulnerable packages with severity level "${minSeverity}" or higher.`
)
}
} catch (error) {
if (error instanceof RequestError && error.status === 404) {
core.setFailed(
@@ -90,29 +93,6 @@ async function run(): Promise<void> {
}
}
function printVulnerabilitiesBlock(
addedChanges: Changes,
minSeverity: Severity
): void {
let failed = false
core.group('Vulnerabilities', async () => {
if (addedChanges.length > 0) {
for (const change of addedChanges) {
printChangeVulnerabilities(change)
}
failed = true
}
if (failed) {
core.setFailed('Dependency review detected vulnerable packages.')
} else {
core.info(
`Dependency review did not detect any vulnerable packages with severity level "${minSeverity}" or higher.`
)
}
})
}
function printChangeVulnerabilities(change: Change): void {
for (const vuln of change.vulnerabilities) {
core.info(
@@ -126,49 +106,6 @@ function printChangeVulnerabilities(change: Change): void {
}
}
function printLicensesBlock(
invalidLicenseChanges: Record<string, Changes>
): void {
core.group('Licenses', async () => {
if (invalidLicenseChanges.forbidden.length > 0) {
core.info('\nThe following dependencies have incompatible licenses:')
printLicensesError(invalidLicenseChanges.forbidden)
core.setFailed('Dependency review detected incompatible licenses.')
}
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: Changes): void {
for (const change of changes) {
core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close} License: ${styles.color.red.open}${change.license}${styles.color.red.close}`
)
}
}
function printNullLicenses(changes: Changes): void {
if (changes.length === 0) {
return
}
core.info('\nWe could not detect a license for the following dependencies:')
for (const change of changes) {
core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close}`
)
}
}
function renderSeverity(
severity: 'critical' | 'high' | 'moderate' | 'low'
): string {
@@ -183,42 +120,30 @@ function renderSeverity(
return `${styles.color[color].open}(${severity} severity)${styles.color[color].close}`
}
function renderScannedDependency(change: Change): string {
const changeType: string = change.change_type
if (changeType !== 'added' && changeType !== 'removed') {
throw new Error(`Unexpected change type: ${changeType}`)
function printLicensesError(changes: Change[]): void {
if (changes.length === 0) {
return
}
const color = (
{
added: 'green',
removed: 'red'
} as const
)[changeType]
const icon = (
{
added: '+',
removed: '-'
} as const
)[changeType]
return `${styles.color[color].open}${icon} ${change.name}@${change.version}${styles.color[color].close}`
core.info('\nThe following dependencies have incompatible licenses:\n')
for (const change of changes) {
core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close} License: ${styles.color.red.open}${change.license}${styles.color.red.close}`
)
}
}
function printScannedDependencies(changes: Changes): void {
core.group('Dependency Changes', async () => {
const dependencies = groupDependenciesByManifest(changes)
function printNullLicenses(changes: Change[]): void {
if (changes.length === 0) {
return
}
for (const manifestName of dependencies.keys()) {
const manifestChanges = dependencies.get(manifestName) || []
core.info(`File: ${styles.bold.open}${manifestName}${styles.bold.close}`)
for (const change of manifestChanges) {
core.info(`${renderScannedDependency(change)}`)
}
}
})
core.info('\nWe could not detect a license for the following dependencies:\n')
for (const change of changes) {
core.info(
`${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close}`
)
}
}
run()
+3 -13
View File
@@ -1,9 +1,6 @@
import * as z from 'zod'
export const SEVERITIES = ['critical', 'high', 'moderate', 'low'] as const
export const SCOPES = ['unknown', 'runtime', 'development'] as const
export const SeveritySchema = z.enum(SEVERITIES).default('low')
export const ChangeSchema = z.object({
change_type: z.enum(['added', 'removed']),
@@ -14,11 +11,10 @@ export const ChangeSchema = z.object({
package_url: z.string(),
license: z.string().nullable(),
source_repository_url: z.string().nullable(),
scope: z.enum(SCOPES).optional(),
vulnerabilities: z
.array(
z.object({
severity: SeveritySchema,
severity: z.enum(['critical', 'high', 'moderate', 'low']),
advisory_ghsa_id: z.string(),
advisory_summary: z.string(),
advisory_url: z.string()
@@ -36,14 +32,9 @@ export const PullRequestSchema = z.object({
export const ConfigurationOptionsSchema = z
.object({
fail_on_severity: SeveritySchema,
fail_on_scopes: z.array(z.enum(SCOPES)).default(['runtime']),
fail_on_severity: z.enum(SEVERITIES).default('low'),
allow_licenses: z.array(z.string()).default([]),
deny_licenses: z.array(z.string()).default([]),
allow_ghsas: z.array(z.string()).default([]),
license_check: z.boolean().default(true),
vulnerability_check: z.boolean().default(true),
config_file: z.string().optional().default('false'),
base_ref: z.string(),
head_ref: z.string()
})
@@ -58,5 +49,4 @@ export const ChangesSchema = z.array(ChangeSchema)
export type Change = z.infer<typeof ChangeSchema>
export type Changes = z.infer<typeof ChangesSchema>
export type ConfigurationOptions = z.infer<typeof ConfigurationOptionsSchema>
export type Severity = z.infer<typeof SeveritySchema>
export type Scope = typeof SCOPES[number]
export type Severity = typeof SEVERITIES[number]
+54 -67
View File
@@ -1,27 +1,17 @@
import * as core from '@actions/core'
import {ConfigurationOptions, Changes} from './schemas'
import {ConfigurationOptions, Change, Changes} from './schemas'
import {SummaryTableRow} from '@actions/core/lib/summary'
import {groupDependenciesByManifest, getManifestsSet, renderUrl} from './utils'
export function addSummaryToSummary(
addedPackages: Changes | null,
invalidLicenseChanges: Record<string, Changes> | null
addedPackages: Changes,
licenseErrors: Change[],
unknownLicenses: Change[]
): 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.`
]
: [])
])
.addRaw(
`We found ${addedPackages.length} vulnerable package(s), ${licenseErrors.length} package(s) with incompatible licenses, and ${unknownLicenses.length} package(s) with unknown licenses.`
)
}
export function addChangeVulnerabilitiesToSummary(
@@ -30,7 +20,7 @@ export function addChangeVulnerabilitiesToSummary(
): void {
const rows: SummaryTableRow[] = []
const manifests = getManifestsSet(addedPackages)
const manifests = getManifests(addedPackages)
core.summary
.addHeading('Vulnerabilities')
@@ -85,7 +75,8 @@ export function addChangeVulnerabilitiesToSummary(
}
export function addLicensesToSummary(
invalidLicenseChanges: Record<string, Changes>,
licenseErrors: Change[],
unknownLicenses: Change[],
config: ConfigurationOptions
): void {
core.summary.addHeading('Licenses')
@@ -101,76 +92,72 @@ export function addLicensesToSummary(
)
}
if (Object.values(invalidLicenseChanges).every(item => item.length === 0)) {
if (licenseErrors.length === 0 && unknownLicenses.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
)
}
function printLicenseViolation(heading: string, changes: Changes): void {
core.summary.addHeading(heading, 5).addSeparator()
if (changes.length > 0) {
if (licenseErrors.length > 0) {
const rows: SummaryTableRow[] = []
const manifests = getManifestsSet(changes)
const manifests = getManifests(licenseErrors)
core.summary.addHeading('Incompatible Licenses', 3).addSeparator()
for (const manifest of manifests) {
core.summary.addHeading(`<em>${manifest}</em>`, 4)
for (const change of changes.filter(pkg => pkg.manifest === manifest)) {
for (const change of licenseErrors.filter(
pkg => pkg.manifest === manifest
)) {
rows.push([
renderUrl(change.source_repository_url, change.name),
change.version,
formatLicense(change.license)
change.license || ''
])
}
core.summary.addTable([['Package', 'Version', 'License'], ...rows])
}
} else {
core.summary.addQuote(`No ${heading.toLowerCase()} detected.`)
core.summary.addQuote('No license violations detected.')
}
}
function formatLicense(license: string | null): string {
if (license === null || license === 'NOASSERTION') {
return 'Null'
}
return license
}
core.debug(`found ${unknownLicenses.length} unknown licenses`)
export function addScannedDependencies(changes: Changes): void {
const dependencies = groupDependenciesByManifest(changes)
const manifests = dependencies.keys()
if (unknownLicenses.length > 0) {
const rows: SummaryTableRow[] = []
const manifests = getManifests(unknownLicenses)
const summary = core.summary
.addHeading('Scanned Dependencies')
.addHeading(`We scanned ${dependencies.size} manifest files:`, 5)
core.debug(
`found ${manifests.entries.length} manifests for unknown licenses`
)
for (const manifest of manifests) {
const deps = dependencies.get(manifest)
if (deps) {
const dependencyNames = deps.map(
dependency => `<li>${dependency.name}@${dependency.version}</li>`
)
summary.addDetails(manifest, `<ul>${dependencyNames.join('')}</ul>`)
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])
}
}
}
function getManifests(changes: Changes): Set<string> {
return new Set(changes.flatMap(c => c.manifest))
}
function renderUrl(url: string | null, text: string): string {
if (url) {
return `<a href="${url}">${text}</a>`
} else {
return text
}
}
-40
View File
@@ -1,40 +0,0 @@
import spdxParse from 'spdx-expression-parse'
import {Changes} from './schemas'
export function groupDependenciesByManifest(
changes: Changes
): Map<string, Changes> {
const dependencies: Map<string, Changes> = new Map()
for (const change of changes) {
const manifestName = change.manifest
if (dependencies.get(manifestName) === undefined) {
dependencies.set(manifestName, [])
}
dependencies.get(manifestName)?.push(change)
}
return dependencies
}
export function getManifestsSet(changes: Changes): Set<string> {
return new Set(changes.flatMap(c => c.manifest))
}
export function renderUrl(url: string | null, text: string): string {
if (url) {
return `<a href="${url}">${text}</a>`
} else {
return text
}
}
export function isSPDXValid(license: string): boolean {
try {
spdxParse(license)
return true
} catch (_) {
return false
}
}