├── .devcontainer └── devcontainer.json ├── .eslintignore ├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE │ └── pull_request_template.md ├── dependabot.yml └── workflows │ ├── check-dist.yml │ ├── ci.yml │ ├── codeql.yml │ ├── dependency-review.yml │ └── stale.yaml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CODEOWNERS ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── __tests__ ├── config.test.ts ├── deny.test.ts ├── dependency-graph.test.ts ├── external-config.test.ts ├── filter.test.ts ├── fixtures │ ├── config-allow-sample.yml │ ├── config-empty-allow-sample.yml │ ├── conflictive-config.yml │ ├── create-test-change.ts │ ├── create-test-vulnerability.ts │ ├── inline-license-config-sample.yml │ ├── invalid-severity-config.yml │ ├── license-config-sample.yml │ └── no-licenses-config.yml ├── licenses.test.ts ├── purl.test.ts ├── scorecard.test.ts ├── spdx.test.ts ├── summary.test.ts └── test-helpers.ts ├── action.yml ├── dist ├── index.js ├── index.js.map ├── licenses.txt └── sourcemap-register.js ├── docs └── examples.md ├── jest.config.js ├── package-lock.json ├── package.json ├── scripts ├── create_summary.ts └── scan_pr ├── src ├── comment-pr.ts ├── config.ts ├── deny.ts ├── dependency-graph.ts ├── filter.ts ├── git-refs.ts ├── licenses.ts ├── main.ts ├── purl.ts ├── schemas.ts ├── scorecard.ts ├── spdx-satisfies.d.ts ├── spdx.ts ├── summary.ts └── utils.ts ├── tsconfig.build.json ├── tsconfig.json └── types └── spdx-license-satisfies.d.ts /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Dependency Review Action", 3 | "image": "mcr.microsoft.com/devcontainers/typescript-node:18", 4 | "postCreateCommand": "npm install", 5 | "remoteUser": "node", 6 | "features": { 7 | "ghcr.io/devcontainers/features/ruby:1": {} 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ 4 | jest.config.js 5 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["jest", "@typescript-eslint"], 3 | "extends": ["plugin:github/recommended"], 4 | "parser": "@typescript-eslint/parser", 5 | "parserOptions": { 6 | "ecmaVersion": 9, 7 | "sourceType": "module", 8 | "project": "./tsconfig.json" 9 | }, 10 | "rules": { 11 | "sort-imports": "off", 12 | "i18n-text/no-en": "off", 13 | "eslint-comments/no-use": "off", 14 | "import/no-namespace": "off", 15 | "no-unused-vars": "off", 16 | "@typescript-eslint/no-unused-vars": ["error", {"argsIgnorePattern": "^_"}], 17 | "@typescript-eslint/explicit-member-accessibility": [ 18 | "error", 19 | {"accessibility": "no-public"} 20 | ], 21 | "@typescript-eslint/no-require-imports": "error", 22 | "@typescript-eslint/array-type": "error", 23 | "@typescript-eslint/await-thenable": "error", 24 | "@typescript-eslint/ban-ts-comment": "error", 25 | "camelcase": "off", 26 | "@typescript-eslint/consistent-type-assertions": "error", 27 | "@typescript-eslint/explicit-function-return-type": [ 28 | "error", 29 | {"allowExpressions": true} 30 | ], 31 | "@typescript-eslint/func-call-spacing": ["error", "never"], 32 | "@typescript-eslint/no-array-constructor": "error", 33 | "@typescript-eslint/no-empty-interface": "error", 34 | "@typescript-eslint/no-explicit-any": "error", 35 | "@typescript-eslint/no-extraneous-class": "error", 36 | "@typescript-eslint/no-for-in-array": "error", 37 | "@typescript-eslint/no-inferrable-types": "error", 38 | "@typescript-eslint/no-misused-new": "error", 39 | "@typescript-eslint/no-namespace": "error", 40 | "@typescript-eslint/no-non-null-assertion": "warn", 41 | "@typescript-eslint/no-unnecessary-qualifier": "error", 42 | "@typescript-eslint/no-unnecessary-type-assertion": "error", 43 | "@typescript-eslint/no-useless-constructor": "error", 44 | "@typescript-eslint/no-var-requires": "error", 45 | "@typescript-eslint/prefer-for-of": "warn", 46 | "@typescript-eslint/prefer-function-type": "warn", 47 | "@typescript-eslint/prefer-includes": "error", 48 | "@typescript-eslint/prefer-string-starts-ends-with": "error", 49 | "@typescript-eslint/promise-function-async": "error", 50 | "@typescript-eslint/require-array-sort-compare": "error", 51 | "@typescript-eslint/restrict-plus-operands": "error", 52 | "semi": "off", 53 | "@typescript-eslint/semi": ["error", "never"], 54 | "@typescript-eslint/type-annotation-spacing": "error", 55 | "@typescript-eslint/unbound-method": "error" 56 | }, 57 | "env": { 58 | "node": true, 59 | "es6": true, 60 | "jest/globals": true 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | dist/** -diff linguist-generated=true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a bug report to help us improve 4 | title: "[BUG] " 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Action version** 27 | What version of the action are you using in your workflow? 28 | 29 | _Note: if you're not running the [latest release](https://github.com/actions/dependency-review-action/releases/latest) please try that first!_ 30 | 31 | **Examples** 32 | If possible, please link to a public example of the issue that you're encountering, or a copy of the workflow that you're using to run the action. 33 | 34 | If you have encountered a problem with a specific package (e.g. issue with license or attributions data) please share details about the package, as well as a link to the manifest where it's being referenced. 35 | 36 | **Additional context** 37 | Add any other context about the problem here. 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: GitHub Security Bug Bounty 4 | url: https://bounty.github.com/ 5 | about: If you believe that you've found a security issue, please report security vulnerabilities here. 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. e.g. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Purpose 2 | 3 | _Describe the purpose of this pull request_ 4 | 5 | ## Related Issues 6 | 7 | _What issues does this PR close or relate to?_ 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: / 5 | schedule: 6 | interval: weekly 7 | 8 | - package-ecosystem: npm 9 | directory: / 10 | schedule: 11 | interval: weekly 12 | ignore: 13 | - dependency-name: '@types/node' 14 | update-types: ['version-update:semver-major'] 15 | groups: 16 | minor-updates: 17 | update-types: 18 | - 'minor' 19 | - 'patch' 20 | exclude-patterns: 21 | - '*spdx*' 22 | # Pull out any updates to spdx definitions and parsing as a priority PR 23 | spdx-licenses: 24 | patterns: 25 | - '*spdx*' 26 | -------------------------------------------------------------------------------- /.github/workflows/check-dist.yml: -------------------------------------------------------------------------------- 1 | # `dist/index.js` is a special file in Actions. 2 | # When you reference an action with `uses:` in a workflow, 3 | # `index.js` is the code that will run. 4 | # For our project, we generate this file through a build process from other source files. 5 | # We need to make sure the checked-in `index.js` actually matches what we expect it to be. 6 | name: Check dist/ 7 | 8 | on: 9 | push: 10 | branches: 11 | - main 12 | paths-ignore: 13 | - '**.md' 14 | pull_request: 15 | paths-ignore: 16 | - '**.md' 17 | workflow_dispatch: 18 | 19 | jobs: 20 | check-dist: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: actions/checkout@v4 25 | 26 | - name: Set Node.js 20.x 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: 20.x 30 | cache: npm 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Rebuild the dist/ directory 36 | run: | 37 | npm run build 38 | npm run package 39 | 40 | - name: Compare the expected and actual dist/ directories 41 | run: | 42 | if [ "$(git diff --ignore-space-at-eol dist/ | wc -l)" -gt "0" ]; then 43 | echo "Detected uncommitted changes after build. See status below:" 44 | git diff 45 | exit 1 46 | fi 47 | id: diff 48 | 49 | # If index.js was different than expected, upload the expected version as an artifact 50 | - uses: actions/upload-artifact@v4 51 | if: ${{ failure() && steps.diff.conclusion == 'failure' }} 52 | with: 53 | name: dist 54 | path: dist/ 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - '**.md' 9 | pull_request: 10 | paths-ignore: 11 | - '**.md' 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: 20 21 | cache: npm 22 | - name: Install dependencies 23 | run: npm ci --ignore-scripts 24 | - name: Test 25 | run: | 26 | npm test 27 | lint: 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions/setup-node@v4 32 | with: 33 | node-version: 20 34 | cache: npm 35 | - name: Install dependencies 36 | run: npm ci --ignore-scripts 37 | - name: Check format 38 | run: | 39 | npm run format-check 40 | - name: Lint 41 | run: | 42 | npm run lint 43 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: '21 0 * * 4' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: 'ubuntu-latest' 15 | timeout-minutes: 360 16 | permissions: 17 | # required for all workflows 18 | security-events: write 19 | 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | language: [ 'javascript-typescript' ] 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | # Initializes the CodeQL tools for scanning. 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v3 32 | with: 33 | languages: ${{ matrix.language }} 34 | # If you wish to specify custom queries, you can do so here or in a config file. 35 | # By default, queries listed here will override any specified in a config file. 36 | # Prefix the list here with "+" to use these queries and those in the config file. 37 | 38 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 39 | # queries: security-extended,security-and-quality 40 | config: | 41 | paths-ignore: 42 | - dist/index.js 43 | - dist/sourcemap-register.js 44 | 45 | - name: Perform CodeQL Analysis 46 | uses: github/codeql-action/analyze@v3 47 | with: 48 | category: "/language:${{matrix.language}}" 49 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | dependency-review: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: 'Checkout Repository' 13 | uses: actions/checkout@v4 14 | - name: Dependency Review 15 | uses: ./ 16 | -------------------------------------------------------------------------------- /.github/workflows/stale.yaml: -------------------------------------------------------------------------------- 1 | name: Close stale PRs and Issues 2 | 3 | permissions: 4 | issues: write 5 | pull-requests: write 6 | 7 | on: 8 | schedule: 9 | - cron: "00 0 * * *" # runs at 00:00 daily 10 | 11 | jobs: 12 | stale: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/stale@v9.1.0 16 | name: Clean up stale PRs and Issues 17 | with: 18 | stale-pr-message: "👋 This pull request has been marked as stale because it has been open with no activity for 180 days. You can: comment on the PR or remove the stale label to hold stalebot off for a while, add the `Keep` label to hold stale off permanently, or do nothing. If you do nothing, this pull request will be closed eventually by the stalebot. Please see CONTRIBUTING.md for more policy details." 19 | stale-pr-label: "Stale" 20 | close-pr-message: "👋 This pull request has been closed by stalebot because it has been open with no activity for over 180 days. Please see CONTRIBUTING.md for more policy details." 21 | stale-issue-label: "Stale" 22 | stale-issue-message: "👋 This issue has been marked as stale because it has been open with no activity for 180 days. You can: comment on the issue or remove the stale label to hold stalebot off for a while, add the `Keep` label to hold stale off permanently, or do nothing. If you do nothing, this issue will be closed eventually by the stalebot. Please see CONTRIBUTING.md for more policy details." 23 | close-issue-message: "👋 This issue has been closed by stalebot because it has been open with no activity for over 180 days. Please see CONTRIBUTING.md for more policy details." 24 | exempt-pr-labels: "Keep" # a "Keep" label will keep the PR from being closed as stale 25 | exempt-issue-labels: "Keep" # a "Keep" label will keep the issue from being closed as stale 26 | days-before-pr-stale: 180 # when the PR is considered stale 27 | days-before-pr-close: 15 # when the PR is closed by the bot 28 | days-before-issue-stale: 180 # when the issue is considered stale 29 | days-before-issue-close: 15 # when the issue is closed by the bot 30 | exempt-assignees: 'advanced-security-dependency-graph' 31 | ascending: true 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | event.json 2 | .ruby-version 3 | 4 | # Dependency directory 5 | node_modules 6 | 7 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | lerna-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | jspm_packages/ 49 | 50 | # TypeScript v1 declaration files 51 | typings/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # next.js build output 79 | .next 80 | 81 | # nuxt.js build output 82 | .nuxt 83 | 84 | # vuepress build output 85 | .vuepress/dist 86 | 87 | # Serverless directories 88 | .serverless/ 89 | 90 | # FuseBox cache 91 | .fusebox/ 92 | 93 | # DynamoDB Local files 94 | .dynamodb/ 95 | 96 | # OS metadata 97 | .DS_Store 98 | Thumbs.db 99 | 100 | # Ignore built ts files 101 | __tests__/runner/* 102 | lib/**/* 103 | 104 | tmp 105 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": false, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid" 10 | } 11 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.1.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Jest Tests", 6 | "type": "node", 7 | "request": "launch", 8 | "runtimeArgs": ["--inspect-brk", "${workspaceRoot}/node_modules/.bin/jest", "--runInBand", "--coverage", "false"], 9 | "console": "integratedTerminal", 10 | "internalConsoleOptions": "neverOpen" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @actions/advanced-security-dependency-graph 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies within all project spaces, and it also applies when 49 | an individual is representing the project or its community in public spaces. 50 | Examples of representing a project or community include using an official 51 | project e-mail address, posting via an official social media account, or acting 52 | as an appointed representative at an online or offline event. Representation of 53 | a project may be further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at opensource@github.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | [fork]: https://github.com/actions/dependency-review-action/fork 4 | [pr]: https://github.com/actions/dependency-review-action/compare 5 | [code-of-conduct]: CODE_OF_CONDUCT.md 6 | 7 | Hi there! We're thrilled that you'd like to contribute to this project. 8 | 9 | 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). 10 | 11 | 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. 12 | 13 | ## Bug reports and other issues 14 | 15 | If you've encountered a problem, please let us know by [submitting an issue](https://github.com/actions/dependency-review-action/issues/new)! 16 | 17 | ## Enhancements and feature requests 18 | 19 | If you've got an idea for a new feature or a significant change to the code or its dependencies, please submit as [an issue](https://github.com/actions/dependency-review-action/issues/new) so that the community can see it, and we can discuss it there. We may not be able to respond to every single issue, but will make a best effort! 20 | 21 | If you'd like to make a contribution yourself, we ask that before significant effort is put into code changes, that we have agreement that the change aligns with our strategy for the action. Since this is a verified Action owned by GitHub we want to make sure that contributions are high quality, and that they maintain consistency with the rest of the action's behavior. 22 | 23 | 1. Create an [issue discussing the idea](https://github.com/actions/dependency-review-action/issues/new), so that we can discuss it there. 24 | 2. If we agree to incorporate the idea into the action, please write-up a high level summary of the approach that you plan to take so we can review 25 | 26 | ## Stalebot 27 | 28 | We have begun using a [Stalebot action](https://github.com/actions/stale) to help keep the Issues and Pull requests backlogs tidy. You can see the configuration [here](.github/workflows/stalebot.yml). If you'd like to keep an issue open after getting a stalebot warning, simply comment on it and it'll reset the clock. 29 | 30 | ## Development lifecycle 31 | 32 | Ready to contribute to `dependency-review-action`? Here is some information to help you get started. 33 | 34 | ### High level overview of the action 35 | 36 | This action makes an authenticated query to the [Dependency Review API](https://docs.github.com/en/rest/dependency-graph/dependency-review) endpoint (`GET /repos/{owner}/{repo}/dependency-graph/compare/{basehead}`) to find out the set of added and removed dependencies for each manifest. 37 | 38 | The action then evaluates the differences between the pushes based on the rules defined in the action configuration, and summarizes the differences and any violations of the rules you have defined as a comment in the pull request that triggered it and the action outputs. 39 | 40 | ### Local Development 41 | 42 | Before you begin, you need to have [Node.js](https://nodejs.org/en/) installed, minimum version 20. 43 | 44 | #### Bootstrapping the project 45 | 46 | 0. [Fork][fork] and clone the repository 47 | 1. Change to the working directory: `cd dependency-review-action` 48 | 2. Install the dependencies: `npm install` 49 | 3. Make sure the tests pass on your machine: `npm run test` 50 | 51 | #### Manually testing for vulnerabilities 52 | 53 | We have a script to scan a given PR for vulnerabilities, this will 54 | 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): 55 | 56 | Screenshot 2022-05-12 at 10 22 21 57 | 58 | The syntax of the script is: 59 | 60 | ```sh 61 | $ GITHUB_TOKEN= ./scripts/scan_pr 62 | ``` 63 | 64 | Like this: 65 | 66 | ```sh 67 | $ GITHUB_TOKEN= ./scripts/scan_pr https://github.com/actions/dependency-review-action/pull/3 68 | ``` 69 | 70 | [Configuration options](README.md#configuration-options) can be set by 71 | passing an external YAML [configuration file](README.md#configuration-file) to the 72 | `scan_pr` script with the `-c`/`--config-file` option: 73 | 74 | ```sh 75 | $ GITHUB_TOKEN= ./scripts/scan_pr --config-file my_custom_config.yml 76 | ``` 77 | 78 | #### Running unit tests 79 | 80 | ``` 81 | npm run test 82 | ``` 83 | 84 | _Note_: We don't have a very comprehensive test suite, so any contributions to the existing tests are welcome! 85 | 86 | ### Submitting a pull request 87 | 88 | 1. Create a new branch: `git checkout -b my-branch-name` 89 | 2. Make your change, add tests, and make sure the tests still pass 90 | 3. Make sure to build and package before pushing: `npm run build && npm run package` 91 | 4. Push to your fork and [submit a pull request][pr] 92 | 93 | Here are a few things you can do that will increase the likelihood of your pull request being accepted: 94 | 95 | - Add unit tests for new features. 96 | - Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests. 97 | - Write a [good commit message](https://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html). 98 | - Add examples of the usage to [examples.md](docs/examples.md) 99 | - Link to a sample PR in a custom repository running your version of the Action. 100 | - Please be responsive to any questions and feedback that you get from a maintainer of the repo! 101 | 102 | ## Cutting a new release 103 | 104 |
105 | 106 | _Note: these instructions are for maintainers_ 107 | 108 | 1. Update the version number in [package.json](https://github.com/actions/dependency-review-action/blob/main/package.json) and run `npm i` to update the lockfile. 109 | 1. Go to [Draft a new 110 | release](https://github.com/actions/dependency-review-action/releases/new) 111 | in the Releases page. 112 | 1. Make sure that the `Publish this Action to the GitHub Marketplace` 113 | checkbox is enabled 114 | 115 | Screenshot 2022-06-15 at 12 08 19 116 | 117 | 3. Click "Choose a tag" and then "Create new tag", where the tag name 118 | will be your version prefixed by a `v` (e.g. `v1.2.3`). 119 | 4. Use a version number for the release title (e.g. "1.2.3"). 120 | 121 | Screenshot 2022-06-15 at 12 08 36 122 | 123 | 5. Add your release notes. If this is a major version make sure to 124 | include a small description of the biggest changes in the new version. 125 | 6. Click "Publish Release". 126 | 127 | You now have a tag and release using the semver version you used 128 | above. The last remaining thing to do is to move the dynamic version 129 | identifier to match the current SHA. This allows users to adopt a 130 | major version number (e.g. `v1`) in their workflows while 131 | automatically getting all the 132 | minor/patch updates. 133 | 134 | To do this just checkout `main`, force-create a new annotated tag, and push it: 135 | 136 | ``` 137 | git tag -fa v4 -m "Updating v4 to 4.0.1" 138 | git push origin v4 --force 139 | ``` 140 |
141 | 142 | 143 | ## Resources 144 | 145 | - [Creating JavaScript GitHub actions](https://docs.github.com/en/actions/creating-actions/creating-a-javascript-action) 146 | - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) 147 | - [Using Pull Requests](https://help.github.com/articles/about-pull-requests/) 148 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 GitHub, Inc. and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | If you discover a security issue in this repo, please submit it through the [GitHub Security Bug Bounty](https://bounty.github.com/) 2 | 3 | Thanks for helping make GitHub Actions safe for everyone. 4 | -------------------------------------------------------------------------------- /__tests__/config.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test, beforeEach} from '@jest/globals' 2 | import {readConfig} from '../src/config' 3 | import {getRefs} from '../src/git-refs' 4 | import * as spdx from '../src/spdx' 5 | import {setInput, clearInputs} from './test-helpers' 6 | 7 | beforeEach(() => { 8 | clearInputs() 9 | }) 10 | 11 | test('it defaults to low severity', async () => { 12 | const config = await readConfig() 13 | expect(config.fail_on_severity).toEqual('low') 14 | }) 15 | 16 | test('it reads custom configs', async () => { 17 | setInput('fail-on-severity', 'critical') 18 | setInput('allow-licenses', 'ISC, GPL-2.0') 19 | 20 | const config = await readConfig() 21 | expect(config.fail_on_severity).toEqual('critical') 22 | expect(config.allow_licenses).toEqual(['ISC', 'GPL-2.0']) 23 | }) 24 | 25 | test('it defaults to false for warn-only', async () => { 26 | const config = await readConfig() 27 | expect(config.warn_only).toEqual(false) 28 | }) 29 | 30 | test('it defaults to empty allow/deny lists ', async () => { 31 | const config = await readConfig() 32 | 33 | expect(config.allow_licenses).toEqual(undefined) 34 | expect(config.deny_licenses).toEqual(undefined) 35 | }) 36 | 37 | test('it raises an error if both an allow and denylist are specified', async () => { 38 | setInput('allow-licenses', 'MIT') 39 | setInput('deny-licenses', 'BSD-3-Clause') 40 | 41 | await expect(readConfig()).rejects.toThrow( 42 | 'You cannot specify both allow-licenses and deny-licenses' 43 | ) 44 | }) 45 | test('it raises an error if an empty allow list is specified', async () => { 46 | setInput('config-file', './__tests__/fixtures/config-empty-allow-sample.yml') 47 | 48 | await expect(readConfig()).rejects.toThrow( 49 | 'You should provide at least one license in allow-licenses' 50 | ) 51 | }) 52 | 53 | test('it successfully parses allow-dependencies-licenses', async () => { 54 | setInput( 55 | 'allow-dependencies-licenses', 56 | 'pkg:npm/@test/package@1.2.3,pkg:npm/example' 57 | ) 58 | const config = await readConfig() 59 | expect(config.allow_dependencies_licenses).toEqual([ 60 | 'pkg:npm/@test/package@1.2.3', 61 | 'pkg:npm/example' 62 | ]) 63 | }) 64 | 65 | test('it raises an error when an invalid package-url is used for allow-dependencies-licenses', async () => { 66 | setInput('allow-dependencies-licenses', 'not-a-purl') 67 | await expect(readConfig()).rejects.toThrow(`Error parsing package-url`) 68 | }) 69 | 70 | test('it raises an error when a nameless package-url is used for allow-dependencies-licenses', async () => { 71 | setInput('allow-dependencies-licenses', 'pkg:npm/@namespace/') 72 | await expect(readConfig()).rejects.toThrow( 73 | `Error parsing package-url: name is required` 74 | ) 75 | }) 76 | 77 | test('it raises an error when an invalid package-url is used for deny-packages', async () => { 78 | setInput('deny-packages', 'not-a-purl') 79 | 80 | await expect(readConfig()).rejects.toThrow(`Error parsing package-url`) 81 | }) 82 | 83 | test('it raises an error when a nameless package-url is used for deny-packages', async () => { 84 | setInput('deny-packages', 'pkg:npm/@namespace/') 85 | 86 | await expect(readConfig()).rejects.toThrow( 87 | `Error parsing package-url: name is required` 88 | ) 89 | }) 90 | 91 | test('it raises an error when an argument to deny-groups is missing a namespace', async () => { 92 | setInput('deny-groups', 'pkg:npm/my-fun-org') 93 | 94 | await expect(readConfig()).rejects.toThrow( 95 | `package-url must have a namespace` 96 | ) 97 | }) 98 | 99 | test('it raises an error when given an unknown severity', async () => { 100 | setInput('fail-on-severity', 'zombies') 101 | 102 | await expect(readConfig()).rejects.toThrow(/received 'zombies'/) 103 | }) 104 | 105 | test('it uses the given refs when the event is not a pull request', async () => { 106 | setInput('base-ref', 'a-custom-base-ref') 107 | setInput('head-ref', 'a-custom-head-ref') 108 | 109 | const refs = getRefs(await readConfig(), { 110 | payload: {}, 111 | eventName: 'workflow_dispatch' 112 | }) 113 | expect(refs.base).toEqual('a-custom-base-ref') 114 | expect(refs.head).toEqual('a-custom-head-ref') 115 | }) 116 | 117 | test('it raises an error when no refs are provided and the event is not a pull request', async () => { 118 | const config = await readConfig() 119 | expect(() => 120 | getRefs(config, { 121 | payload: {}, 122 | eventName: 'workflow_dispatch' 123 | }) 124 | ).toThrow() 125 | }) 126 | 127 | const pullRequestLikeEvents = ['pull_request', 'pull_request_target'] 128 | 129 | test.each(pullRequestLikeEvents)( 130 | 'it uses the given refs even when the event is %s', 131 | async eventName => { 132 | setInput('base-ref', 'a-custom-base-ref') 133 | setInput('head-ref', 'a-custom-head-ref') 134 | 135 | const refs = getRefs(await readConfig(), { 136 | payload: { 137 | pull_request: { 138 | number: 42, 139 | base: {sha: 'pr-base-ref'}, 140 | head: {sha: 'pr-head-ref'} 141 | } 142 | }, 143 | eventName 144 | }) 145 | expect(refs.base).toEqual('a-custom-base-ref') 146 | expect(refs.head).toEqual('a-custom-head-ref') 147 | } 148 | ) 149 | 150 | test.each(pullRequestLikeEvents)( 151 | 'it uses the event refs when the event is %s and no refs are provided in config', 152 | async eventName => { 153 | const refs = getRefs(await readConfig(), { 154 | payload: { 155 | pull_request: { 156 | number: 42, 157 | base: {sha: 'pr-base-ref'}, 158 | head: {sha: 'pr-head-ref'} 159 | } 160 | }, 161 | eventName 162 | }) 163 | expect(refs.base).toEqual('pr-base-ref') 164 | expect(refs.head).toEqual('pr-head-ref') 165 | } 166 | ) 167 | 168 | test('it uses the given refs even when the event is merge_group', async () => { 169 | setInput('base-ref', 'a-custom-base-ref') 170 | setInput('head-ref', 'a-custom-head-ref') 171 | 172 | const refs = getRefs(await readConfig(), { 173 | payload: { 174 | merge_group: { 175 | base_sha: 'pr-base-ref', 176 | head_sha: 'pr-head-ref' 177 | } 178 | }, 179 | eventName: 'merge_group' 180 | }) 181 | expect(refs.base).toEqual('a-custom-base-ref') 182 | expect(refs.head).toEqual('a-custom-head-ref') 183 | }) 184 | 185 | test('it uses the event refs when the event is merge_group and no refs are provided in config', async () => { 186 | const refs = getRefs(await readConfig(), { 187 | payload: { 188 | merge_group: { 189 | base_sha: 'pr-base-ref', 190 | head_sha: 'pr-head-ref' 191 | } 192 | }, 193 | eventName: 'merge_group' 194 | }) 195 | expect(refs.base).toEqual('pr-base-ref') 196 | expect(refs.head).toEqual('pr-head-ref') 197 | }) 198 | 199 | test('it defaults to runtime scope', async () => { 200 | const config = await readConfig() 201 | expect(config.fail_on_scopes).toEqual(['runtime']) 202 | }) 203 | 204 | test('it parses custom scopes preference', async () => { 205 | setInput('fail-on-scopes', 'runtime, development') 206 | let config = await readConfig() 207 | expect(config.fail_on_scopes).toEqual(['runtime', 'development']) 208 | 209 | clearInputs() 210 | setInput('fail-on-scopes', 'development') 211 | config = await readConfig() 212 | expect(config.fail_on_scopes).toEqual(['development']) 213 | }) 214 | 215 | test('it raises an error when given invalid scope', async () => { 216 | setInput('fail-on-scopes', 'runtime, zombies') 217 | await expect(readConfig()).rejects.toThrow(/received 'zombies'/) 218 | }) 219 | 220 | test('it defaults to an empty GHSA allowlist', async () => { 221 | const config = await readConfig() 222 | expect(config.allow_ghsas).toEqual([]) 223 | }) 224 | 225 | test('it successfully parses GHSA allowlist', async () => { 226 | setInput('allow-ghsas', 'GHSA-abcd-1234-5679, GHSA-efgh-1234-5679') 227 | const config = await readConfig() 228 | expect(config.allow_ghsas).toEqual([ 229 | 'GHSA-abcd-1234-5679', 230 | 'GHSA-efgh-1234-5679' 231 | ]) 232 | }) 233 | 234 | test('it defaults to checking licenses', async () => { 235 | const config = await readConfig() 236 | expect(config.license_check).toBe(true) 237 | }) 238 | 239 | test('it parses the license-check input', async () => { 240 | setInput('license-check', 'false') 241 | let config = await readConfig() 242 | expect(config.license_check).toEqual(false) 243 | 244 | clearInputs() 245 | setInput('license-check', 'true') 246 | config = await readConfig() 247 | expect(config.license_check).toEqual(true) 248 | }) 249 | 250 | test('it defaults to checking vulnerabilities', async () => { 251 | const config = await readConfig() 252 | expect(config.vulnerability_check).toBe(true) 253 | }) 254 | 255 | test('it parses the vulnerability-check input', async () => { 256 | setInput('vulnerability-check', 'false') 257 | let config = await readConfig() 258 | expect(config.vulnerability_check).toEqual(false) 259 | 260 | clearInputs() 261 | setInput('vulnerability-check', 'true') 262 | config = await readConfig() 263 | expect(config.vulnerability_check).toEqual(true) 264 | }) 265 | 266 | test('it is not possible to disable both checks', async () => { 267 | setInput('license-check', 'false') 268 | setInput('vulnerability-check', 'false') 269 | await expect(readConfig()).rejects.toThrow( 270 | /Can't disable both license-check and vulnerability-check/ 271 | ) 272 | }) 273 | 274 | describe('licenses that are not valid SPDX licenses', () => { 275 | test('it raises an error for invalid licenses in allow-licenses', async () => { 276 | setInput('allow-licenses', ' BSD-YOLO, GPL-2.0') 277 | await expect(readConfig()).rejects.toThrow( 278 | 'Invalid license(s) in allow-licenses: BSD-YOLO' 279 | ) 280 | }) 281 | 282 | test('it raises an error for invalid licenses in deny-licenses', async () => { 283 | setInput('deny-licenses', ' GPL-2.0, BSD-YOLO, Apache-2.0, ToIll') 284 | await expect(readConfig()).rejects.toThrow( 285 | 'Invalid license(s) in deny-licenses: BSD-YOLO, ToIll' 286 | ) 287 | }) 288 | }) 289 | 290 | test('it parses the comment-summary-in-pr input', async () => { 291 | setInput('comment-summary-in-pr', 'true') 292 | let config = await readConfig() 293 | expect(config.comment_summary_in_pr).toBe('always') 294 | 295 | clearInputs() 296 | setInput('comment-summary-in-pr', 'false') 297 | config = await readConfig() 298 | expect(config.comment_summary_in_pr).toBe('never') 299 | 300 | clearInputs() 301 | setInput('comment-summary-in-pr', 'always') 302 | config = await readConfig() 303 | expect(config.comment_summary_in_pr).toBe('always') 304 | 305 | clearInputs() 306 | setInput('comment-summary-in-pr', 'never') 307 | config = await readConfig() 308 | expect(config.comment_summary_in_pr).toBe('never') 309 | 310 | clearInputs() 311 | setInput('comment-summary-in-pr', 'on-failure') 312 | config = await readConfig() 313 | expect(config.comment_summary_in_pr).toBe('on-failure') 314 | }) 315 | -------------------------------------------------------------------------------- /__tests__/deny.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, jest, test} from '@jest/globals' 2 | import {Change, Changes} from '../src/schemas' 3 | import {createTestChange, createTestPURLs} from './fixtures/create-test-change' 4 | import {getDeniedChanges} from '../src/deny' 5 | 6 | jest.mock('@actions/core') 7 | 8 | const mockOctokit = { 9 | rest: { 10 | licenses: { 11 | getForRepo: jest 12 | .fn() 13 | .mockReturnValue({data: {license: {spdx_id: 'AGPL'}}}) 14 | } 15 | } 16 | } 17 | 18 | let npmChange: Change 19 | let rubyChange: Change 20 | let pipChange: Change 21 | let mvnChange: Change 22 | 23 | jest.mock('octokit', () => { 24 | return { 25 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 26 | Octokit: class { 27 | constructor() { 28 | return mockOctokit 29 | } 30 | } 31 | } 32 | }) 33 | 34 | beforeEach(async () => { 35 | jest.resetModules() 36 | 37 | npmChange = createTestChange({ecosystem: 'npm'}) 38 | rubyChange = createTestChange({ecosystem: 'rubygems'}) 39 | pipChange = createTestChange({ecosystem: 'pip'}) 40 | mvnChange = createTestChange({ecosystem: 'maven'}) 41 | }) 42 | 43 | test('denies packages from the deny packages list', async () => { 44 | const changes: Changes = [npmChange, rubyChange] 45 | const deniedPackages = createTestPURLs(['pkg:gem/actionsomething@3.2.0']) 46 | const deniedChanges = await getDeniedChanges(changes, deniedPackages) 47 | 48 | expect(deniedChanges[0]).toBe(rubyChange) 49 | expect(deniedChanges.length).toEqual(1) 50 | }) 51 | 52 | test('denies packages only for the specified version from deny packages list', async () => { 53 | const deniedPackageWithDifferentVersion = createTestPURLs([ 54 | 'pkg:npm/lodash@1.2.3' 55 | ]) 56 | const changes: Changes = [npmChange] 57 | const deniedChanges = await getDeniedChanges( 58 | changes, 59 | deniedPackageWithDifferentVersion 60 | ) 61 | 62 | expect(deniedChanges.length).toEqual(0) 63 | }) 64 | 65 | test('if no specified version from deny packages list, it will treat package as wildcard and deny all versions', async () => { 66 | const changes: Changes = [ 67 | createTestChange({name: 'lodash', version: '1.2.3'}), 68 | createTestChange({name: 'lodash', version: '4.5.6'}), 69 | createTestChange({name: 'lodash', version: '7.8.9'}) 70 | ] 71 | const denyAllLodashVersions = createTestPURLs(['pkg:npm/lodash']) 72 | const deniedChanges = await getDeniedChanges(changes, denyAllLodashVersions) 73 | 74 | expect(deniedChanges.length).toEqual(3) 75 | }) 76 | 77 | test('denies packages from the deny group list', async () => { 78 | const changes: Changes = [mvnChange, rubyChange] 79 | const deniedGroups = createTestPURLs(['pkg:maven/org.apache.logging.log4j/']) 80 | const deniedChanges = await getDeniedChanges(changes, [], deniedGroups) 81 | 82 | expect(deniedChanges[0]).toBe(mvnChange) 83 | expect(deniedChanges.length).toEqual(1) 84 | }) 85 | 86 | test('denies packages that match the deny group list exactly', async () => { 87 | const changes: Changes = [ 88 | createTestChange({ 89 | package_url: 'pkg:npm/org.test.pass/pass-this@1.0.0', 90 | ecosystem: 'npm' 91 | }), 92 | createTestChange({ 93 | package_url: 'pkg:npm/org.test/deny-this@1.0.0', 94 | ecosystem: 'npm' 95 | }) 96 | ] 97 | const deniedGroups = createTestPURLs(['pkg:npm/org.test/']) 98 | const deniedChanges = await getDeniedChanges(changes, [], deniedGroups) 99 | 100 | expect(deniedChanges.length).toEqual(1) 101 | expect(deniedChanges[0]).toBe(changes[1]) 102 | }) 103 | 104 | test(`denies packages using the namespace from the name when there's no package_url`, async () => { 105 | const changes: Changes = [ 106 | createTestChange({ 107 | package_url: 'pkg:npm/org.test.pass/pass-this@1.0.0', 108 | ecosystem: 'npm' 109 | }), 110 | createTestChange({ 111 | name: 'org.test:deny-this', 112 | package_url: '', 113 | ecosystem: 'maven' 114 | }) 115 | ] 116 | const deniedGroups = createTestPURLs(['pkg:maven/org.test/']) 117 | const deniedChanges = await getDeniedChanges(changes, [], deniedGroups) 118 | 119 | expect(deniedChanges.length).toEqual(1) 120 | expect(deniedChanges[0]).toBe(changes[1]) 121 | }) 122 | 123 | test('allows packages not defined in the deny packages and groups list', async () => { 124 | const changes: Changes = [npmChange, pipChange] 125 | const deniedPackages = createTestPURLs([ 126 | 'pkg:gem/package-not-in-changes@1.0.0' 127 | ]) 128 | const deniedGroups = createTestPURLs(['pkg:maven/group.not.in.changes/']) 129 | const deniedChanges = await getDeniedChanges( 130 | changes, 131 | deniedPackages, 132 | deniedGroups 133 | ) 134 | 135 | expect(deniedChanges.length).toEqual(0) 136 | }) 137 | 138 | test('deny packages does not prevent removal of denied packages', async () => { 139 | const changes: Changes = [ 140 | createTestChange({ 141 | change_type: 'added', 142 | name: 'deny-by-name-and-version', 143 | version: '1.0.0', 144 | ecosystem: 'npm' 145 | }), 146 | createTestChange({ 147 | change_type: 'removed', 148 | name: 'pass-by-name-and-version', 149 | version: '1.0.0', 150 | ecosystem: 'npm' 151 | }), 152 | createTestChange({ 153 | change_type: 'added', 154 | name: 'deny-by-name', 155 | version: '1.0.0', 156 | ecosystem: 'npm' 157 | }), 158 | createTestChange({ 159 | change_type: 'removed', 160 | name: 'pass-by-name', 161 | version: '1.0.0', 162 | ecosystem: 'npm' 163 | }), 164 | createTestChange({ 165 | change_type: 'added', 166 | package_url: 'pkg:npm/org.test.deny.by.namespace/only@1.0.0', 167 | ecosystem: 'npm' 168 | }), 169 | createTestChange({ 170 | change_type: 'removed', 171 | package_url: 'pkg:npm/org.test.pass.by.namespace/only@1.0.0', 172 | ecosystem: 'npm' 173 | }) 174 | ] 175 | const deniedPackages = createTestPURLs([ 176 | 'pkg:npm/org.test.deny.by/deny-by-name-and-version@1.0.0', 177 | 'pkg:npm/org.test.pass.by/pass-by-name-and-version@1.0.0', 178 | 'pkg:npm/org.test.deny.by/deny-by-name', 179 | 'pkg:npm/org.test.pass.by/pass-by-name' 180 | ]) 181 | const deniedGroups = createTestPURLs([ 182 | 'pkg:npm/org.test.deny.by.namespace/', 183 | 'pkg:npm/org.test.pass.by.namespace/' 184 | ]) 185 | const deniedChanges = await getDeniedChanges( 186 | changes, 187 | deniedPackages, 188 | deniedGroups 189 | ) 190 | 191 | expect(deniedChanges.length).toEqual(3) 192 | expect(deniedChanges[0]).toBe(changes[0]) 193 | expect(deniedChanges[1]).toBe(changes[2]) 194 | expect(deniedChanges[2]).toBe(changes[4]) 195 | }) 196 | -------------------------------------------------------------------------------- /__tests__/dependency-graph.test.ts: -------------------------------------------------------------------------------- 1 | import {RequestError} from '@octokit/request-error' 2 | import * as dependencyGraph from '../src/dependency-graph' 3 | import * as core from '@actions/core' 4 | 5 | // mock call to core.getInput('repo-token'.. to avoid environment setup - Input required and not supplied: repo-token 6 | jest.mock('@actions/core', () => ({ 7 | getInput: (input: string) => { 8 | if (input === 'repo-token') { 9 | return 'gh_testtoken' 10 | } 11 | } 12 | })) 13 | 14 | test('it properly catches RequestError type', async () => { 15 | const token = core.getInput('repo-token', {required: true}) 16 | expect(token).toBe('gh_testtoken') 17 | 18 | //Integration test to make an API request using current dependencies and ensure response can parse into RequestError 19 | try { 20 | await dependencyGraph.compare({ 21 | owner: 'actions', 22 | repo: 'dependency-review-action', 23 | baseRef: 'refs/heads/master', 24 | headRef: 'refs/heads/master' 25 | }) 26 | } catch (error) { 27 | expect(error).toBeInstanceOf(RequestError) 28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /__tests__/external-config.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test, beforeEach} from '@jest/globals' 2 | import {readConfig} from '../src/config' 3 | import * as spdx from '../src/spdx' 4 | import {setInput, clearInputs} from './test-helpers' 5 | 6 | const externalConfig = `fail_on_severity: 'high' 7 | allow_licenses: ['GPL-2.0-only'] 8 | ` 9 | const mockOctokit = { 10 | rest: { 11 | repos: { 12 | getContent: jest.fn().mockReturnValue({data: externalConfig}) 13 | } 14 | } 15 | } 16 | 17 | jest.mock('octokit', () => { 18 | return { 19 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 20 | Octokit: class { 21 | constructor() { 22 | return mockOctokit 23 | } 24 | } 25 | } 26 | }) 27 | 28 | beforeEach(() => { 29 | clearInputs() 30 | }) 31 | 32 | test('it reads an external config file', async () => { 33 | setInput('config-file', './__tests__/fixtures/config-allow-sample.yml') 34 | 35 | const config = await readConfig() 36 | expect(config.fail_on_severity).toEqual('critical') 37 | expect(config.allow_licenses).toEqual(['BSD-3-Clause', 'GPL-2.0']) 38 | }) 39 | 40 | test('raises an error when the config file was not found', async () => { 41 | setInput('config-file', 'fixtures/i-dont-exist') 42 | await expect(readConfig()).rejects.toThrow(/Unable to fetch/) 43 | }) 44 | 45 | test('it parses options from both sources', async () => { 46 | setInput('config-file', './__tests__/fixtures/config-allow-sample.yml') 47 | 48 | let config = await readConfig() 49 | expect(config.fail_on_severity).toEqual('critical') 50 | 51 | setInput('base-ref', 'a-custom-base-ref') 52 | config = await readConfig() 53 | expect(config.base_ref).toEqual('a-custom-base-ref') 54 | }) 55 | 56 | test('in case of conflicts, the inline config is the source of truth', async () => { 57 | setInput('fail-on-severity', 'low') 58 | setInput('config-file', './__tests__/fixtures/config-allow-sample.yml') // this will set fail-on-severity to 'critical' 59 | 60 | const config = await readConfig() 61 | expect(config.fail_on_severity).toEqual('low') 62 | }) 63 | 64 | test('it uses the default values when loading external files', async () => { 65 | setInput('config-file', './__tests__/fixtures/no-licenses-config.yml') 66 | let config = await readConfig() 67 | expect(config.allow_licenses).toEqual(undefined) 68 | expect(config.deny_licenses).toEqual(undefined) 69 | 70 | setInput('config-file', './__tests__/fixtures/license-config-sample.yml') 71 | config = await readConfig() 72 | expect(config.fail_on_severity).toEqual('low') 73 | }) 74 | 75 | test('it accepts an external configuration filename', async () => { 76 | setInput('config-file', './__tests__/fixtures/no-licenses-config.yml') 77 | const config = await readConfig() 78 | expect(config.fail_on_severity).toEqual('critical') 79 | }) 80 | 81 | test('it raises an error when given an unknown severity in an external config file', async () => { 82 | setInput('config-file', './__tests__/fixtures/invalid-severity-config.yml') 83 | await expect(readConfig()).rejects.toThrow() 84 | }) 85 | 86 | test('it supports comma-separated lists', async () => { 87 | setInput( 88 | 'config-file', 89 | './__tests__/fixtures/inline-license-config-sample.yml' 90 | ) 91 | const config = await readConfig() 92 | 93 | expect(config.allow_licenses).toEqual(['MIT', 'GPL-2.0-only']) 94 | }) 95 | 96 | test('it reads a config file hosted in another repo', async () => { 97 | setInput( 98 | 'config-file', 99 | 'future-funk/anyone-cualkiera/external-config.yml@main' 100 | ) 101 | setInput('external-repo-token', 'gh_viptoken') 102 | 103 | const config = await readConfig() 104 | 105 | expect(config.fail_on_severity).toEqual('high') 106 | expect(config.allow_licenses).toEqual(['GPL-2.0-only']) 107 | }) 108 | -------------------------------------------------------------------------------- /__tests__/filter.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@jest/globals' 2 | import {Change} from '../src/schemas' 3 | import { 4 | filterChangesBySeverity, 5 | filterChangesByScopes, 6 | filterAllowedAdvisories 7 | } from '../src/filter' 8 | 9 | const npmChange: Change = { 10 | manifest: 'package.json', 11 | change_type: 'added', 12 | ecosystem: 'npm', 13 | name: 'Reeuhq', 14 | version: '1.0.2', 15 | package_url: 'pkg:npm/reeuhq@1.0.2', 16 | license: 'MIT', 17 | source_repository_url: 'github.com/some-repo', 18 | scope: 'runtime', 19 | vulnerabilities: [ 20 | { 21 | severity: 'critical', 22 | advisory_ghsa_id: 'vulnerable-ghsa-id', 23 | advisory_summary: 'very dangerous', 24 | advisory_url: 'github.com/future-funk' 25 | } 26 | ] 27 | } 28 | 29 | const rubyChange: Change = { 30 | change_type: 'added', 31 | manifest: 'Gemfile.lock', 32 | ecosystem: 'rubygems', 33 | name: 'actionsomething', 34 | version: '3.2.0', 35 | package_url: 'pkg:gem/actionsomething@3.2.0', 36 | license: 'BSD', 37 | source_repository_url: 'github.com/some-repo', 38 | scope: 'development', 39 | vulnerabilities: [ 40 | { 41 | severity: 'moderate', 42 | advisory_ghsa_id: 'moderate-ghsa-id', 43 | advisory_summary: 'not so dangerous', 44 | advisory_url: 'github.com/future-funk' 45 | }, 46 | { 47 | severity: 'low', 48 | advisory_ghsa_id: 'low-ghsa-id', 49 | advisory_summary: 'dont page me', 50 | advisory_url: 'github.com/future-funk' 51 | } 52 | ] 53 | } 54 | 55 | const noVulnNpmChange: Change = { 56 | manifest: 'package.json', 57 | change_type: 'added', 58 | ecosystem: 'npm', 59 | name: 'helpful', 60 | version: '1.0.0', 61 | package_url: 'pkg:npm/helpful@1.0.0', 62 | license: 'MIT', 63 | source_repository_url: 'github.com/some-repo', 64 | scope: 'runtime', 65 | vulnerabilities: [] 66 | } 67 | 68 | const lodashChange: Change = { 69 | change_type: 'added', 70 | manifest: 'package.json', 71 | ecosystem: 'npm', 72 | name: 'lodash', 73 | version: '4.17.0', 74 | package_url: 'pkg:npm/lodash@4.17.0', 75 | license: 'MIT', 76 | source_repository_url: 'https://github.com/lodash/lodash', 77 | scope: 'runtime', 78 | vulnerabilities: [ 79 | { 80 | severity: 'critical', 81 | advisory_ghsa_id: 'GHSA-jf85-cpcp-j695', 82 | advisory_summary: 'Prototype Pollution in lodash', 83 | advisory_url: 'https://github.com/advisories/GHSA-jf85-cpcp-j695' 84 | }, 85 | { 86 | severity: 'high', 87 | advisory_ghsa_id: 'GHSA-4xc9-xhrj-v574', 88 | advisory_summary: 'Prototype Pollution in lodash', 89 | advisory_url: 'https://github.com/advisories/GHSA-4xc9-xhrj-v574' 90 | }, 91 | { 92 | severity: 'high', 93 | advisory_ghsa_id: 'GHSA-35jh-r3h4-6jhm', 94 | advisory_summary: 'Command Injection in lodash', 95 | advisory_url: 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm' 96 | }, 97 | { 98 | severity: 'high', 99 | advisory_ghsa_id: 'GHSA-p6mc-m468-83gw', 100 | advisory_summary: 'Prototype Pollution in lodash', 101 | advisory_url: 'https://github.com/advisories/GHSA-p6mc-m468-83gw' 102 | }, 103 | { 104 | severity: 'moderate', 105 | advisory_ghsa_id: 'GHSA-x5rq-j2xg-h7qm', 106 | advisory_summary: 107 | 'Regular Expression Denial of Service (ReDoS) in lodash', 108 | advisory_url: 'https://github.com/advisories/GHSA-x5rq-j2xg-h7qm' 109 | }, 110 | { 111 | severity: 'moderate', 112 | advisory_ghsa_id: 'GHSA-29mw-wpgm-hmr9', 113 | advisory_summary: 114 | 'Regular Expression Denial of Service (ReDoS) in lodash', 115 | advisory_url: 'https://github.com/advisories/GHSA-29mw-wpgm-hmr9' 116 | }, 117 | { 118 | severity: 'low', 119 | advisory_ghsa_id: 'GHSA-fvqr-27wr-82fm', 120 | advisory_summary: 'Prototype Pollution in lodash', 121 | advisory_url: 'https://github.com/advisories/GHSA-fvqr-27wr-82fm' 122 | } 123 | ] 124 | } 125 | 126 | test('it properly filters changes by severity', async () => { 127 | const changes = [npmChange, rubyChange] 128 | let result = filterChangesBySeverity('high', changes) 129 | expect(result).toEqual([npmChange]) 130 | 131 | result = filterChangesBySeverity('low', changes) 132 | expect(changes).toEqual([npmChange, rubyChange]) 133 | 134 | result = filterChangesBySeverity('critical', changes) 135 | expect(changes).toEqual([npmChange, rubyChange]) 136 | }) 137 | 138 | test('it properly filters changes by scope', async () => { 139 | const changes = [npmChange, rubyChange] 140 | 141 | let result = filterChangesByScopes(['runtime'], changes) 142 | expect(result).toEqual([npmChange]) 143 | 144 | result = filterChangesByScopes(['development'], changes) 145 | expect(result).toEqual([rubyChange]) 146 | 147 | result = filterChangesByScopes(['runtime', 'development'], changes) 148 | expect(result).toEqual([npmChange, rubyChange]) 149 | }) 150 | 151 | test('it properly handles undefined advisory IDs', async () => { 152 | const changes = [npmChange, rubyChange, noVulnNpmChange] 153 | const result = filterAllowedAdvisories(undefined, changes) 154 | expect(result).toEqual([npmChange, rubyChange, noVulnNpmChange]) 155 | }) 156 | 157 | test('it properly filters changes with allowed vulnerabilities', async () => { 158 | const changes = [npmChange, rubyChange, noVulnNpmChange] 159 | 160 | const fakeGHSAChanges = filterAllowedAdvisories(['notrealGHSAID'], changes) 161 | expect(fakeGHSAChanges).toEqual([npmChange, rubyChange, noVulnNpmChange]) 162 | }) 163 | 164 | test('it properly filters only allowed vulnerabilities', async () => { 165 | const changes = [npmChange, rubyChange, noVulnNpmChange] 166 | const oldVulns = [ 167 | ...npmChange.vulnerabilities, 168 | ...rubyChange.vulnerabilities, 169 | ...noVulnNpmChange.vulnerabilities 170 | ] 171 | 172 | const vulnerable = filterAllowedAdvisories(['vulnerable-ghsa-id'], changes) 173 | 174 | const newVulns = vulnerable.map(change => change.vulnerabilities).flat() 175 | 176 | expect(newVulns.length).toEqual(oldVulns.length - 1) 177 | expect(newVulns).not.toContainEqual( 178 | expect.objectContaining({advisory_ghsa_id: 'vulnerable-ghsa-id'}) 179 | ) 180 | }) 181 | 182 | test('does not drop dependencies when filtering by GHSA', async () => { 183 | const changes = [npmChange, rubyChange, noVulnNpmChange] 184 | const result = filterAllowedAdvisories( 185 | ['moderate-ghsa-id', 'low-ghsa-id', 'GHSA-jf85-cpcp-j695'], 186 | changes 187 | ) 188 | 189 | expect(result.map(change => change.name)).toEqual( 190 | changes.map(change => change.name) 191 | ) 192 | }) 193 | 194 | test('it properly filters multiple GHSAs', async () => { 195 | const allowedGHSAs = ['vulnerable-ghsa-id', 'moderate-ghsa-id', 'low-ghsa-id'] 196 | const changes = [npmChange, rubyChange, noVulnNpmChange] 197 | const oldVulns = changes.map(change => change.vulnerabilities).flat() 198 | 199 | const result = filterAllowedAdvisories(allowedGHSAs, changes) 200 | 201 | const newVulns = result.map(change => change.vulnerabilities).flat() 202 | 203 | expect(newVulns.length).toEqual(oldVulns.length - 3) 204 | }) 205 | 206 | test('it filters out GHSA dependencies', async () => { 207 | const lodash = filterAllowedAdvisories( 208 | ['GHSA-jf85-cpcp-j695'], 209 | [lodashChange] 210 | )[0] 211 | // the filter should have removed a single GHSA from the list 212 | const expected = lodashChange.vulnerabilities.filter( 213 | vuln => vuln.advisory_ghsa_id !== 'GHSA-jf85-cpcp-j695' 214 | ) 215 | expect(expected.length).toEqual(lodashChange.vulnerabilities.length - 1) 216 | expect(lodash.vulnerabilities).toEqual(expected) 217 | }) 218 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-allow-sample.yml: -------------------------------------------------------------------------------- 1 | fail_on_severity: critical 2 | allow_licenses: 3 | - 'BSD-3-Clause' 4 | - 'GPL-2.0' 5 | -------------------------------------------------------------------------------- /__tests__/fixtures/config-empty-allow-sample.yml: -------------------------------------------------------------------------------- 1 | fail_on_severity: critical 2 | allow_licenses: [] 3 | -------------------------------------------------------------------------------- /__tests__/fixtures/conflictive-config.yml: -------------------------------------------------------------------------------- 1 | allow_licenses: [] 2 | deny_licenses: [] 3 | -------------------------------------------------------------------------------- /__tests__/fixtures/create-test-change.ts: -------------------------------------------------------------------------------- 1 | import {Change} from '../../src/schemas' 2 | import {createTestVulnerability} from './create-test-vulnerability' 3 | import {PackageURL, parsePURL} from '../../src/purl' 4 | 5 | const defaultNpmChange: Change = { 6 | change_type: 'added', 7 | manifest: 'package.json', 8 | ecosystem: 'npm', 9 | name: 'lodash', 10 | version: '4.17.20', 11 | package_url: 'pkg:npm/lodash@4.17.20', 12 | license: 'MIT', 13 | source_repository_url: 'https://github.com/lodash/lodash', 14 | scope: 'runtime', 15 | vulnerabilities: [ 16 | createTestVulnerability({ 17 | severity: 'high', 18 | advisory_ghsa_id: 'GHSA-35jh-r3h4-6jhm', 19 | advisory_summary: 'Command Injection in lodash', 20 | advisory_url: 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm' 21 | }), 22 | createTestVulnerability({ 23 | severity: 'moderate', 24 | advisory_ghsa_id: 'GHSA-29mw-wpgm-hmr9', 25 | advisory_summary: 26 | 'Regular Expression Denial of Service (ReDoS) in lodash', 27 | advisory_url: 'https://github.com/advisories/GHSA-29mw-wpgm-hmr9' 28 | }) 29 | ] 30 | } 31 | 32 | const defaultRubyChange: Change = { 33 | change_type: 'added', 34 | manifest: 'Gemfile.lock', 35 | ecosystem: 'rubygems', 36 | name: 'actionsomething', 37 | version: '3.2.0', 38 | package_url: 'pkg:gem/actionsomething@3.2.0', 39 | license: 'BSD', 40 | source_repository_url: 'github.com/some-repo', 41 | scope: 'runtime', 42 | vulnerabilities: [ 43 | { 44 | severity: 'moderate', 45 | advisory_ghsa_id: 'second-random_string', 46 | advisory_summary: 'not so dangerous', 47 | advisory_url: 'github.com/future-funk' 48 | }, 49 | { 50 | severity: 'low', 51 | advisory_ghsa_id: 'third-random_string', 52 | advisory_summary: 'dont page me', 53 | advisory_url: 'github.com/future-funk' 54 | } 55 | ] 56 | } 57 | 58 | const defaultPipChange: Change = { 59 | change_type: 'added', 60 | manifest: 'requirements.txt', 61 | ecosystem: 'pip', 62 | name: 'package-1', 63 | version: '1.1.1', 64 | package_url: 'pkg:pypi/package-1@1.1.1', 65 | license: 'MIT', 66 | source_repository_url: 'github.com/some-repo', 67 | scope: 'runtime', 68 | vulnerabilities: [ 69 | { 70 | severity: 'moderate', 71 | advisory_ghsa_id: 'second-random_string', 72 | advisory_summary: 'not so dangerous', 73 | advisory_url: 'github.com/future-funk' 74 | }, 75 | { 76 | severity: 'low', 77 | advisory_ghsa_id: 'third-random_string', 78 | advisory_summary: 'dont page me', 79 | advisory_url: 'github.com/future-funk' 80 | } 81 | ] 82 | } 83 | 84 | const defaultMavenChange: Change = { 85 | change_type: 'added', 86 | manifest: 'pom.xml', 87 | ecosystem: 'maven', 88 | name: 'org.apache.logging.log4j:log4j-core', 89 | version: '2.15.0', 90 | package_url: 'pkg:maven/org.apache.logging.log4j/log4j-core@2.14.7', 91 | license: 'Apache-2.0', 92 | source_repository_url: 93 | 'https://mvnrepository.com/artifact/org.apache.logging.log4j/log4j-core', 94 | scope: 'unknown', 95 | vulnerabilities: [ 96 | { 97 | severity: 'critical', 98 | advisory_ghsa_id: 'second-random_string', 99 | advisory_summary: 'not so dangerous', 100 | advisory_url: 'github.com/future-funk' 101 | } 102 | ] 103 | } 104 | 105 | const ecosystemToDefaultChange: {[key: string]: Change} = { 106 | npm: defaultNpmChange, 107 | rubygems: defaultRubyChange, 108 | pip: defaultPipChange, 109 | maven: defaultMavenChange 110 | } 111 | 112 | const createTestChange = (overwrites: Partial = {}): Change => { 113 | const ecosystem = overwrites.ecosystem || 'npm' 114 | return { 115 | ...ecosystemToDefaultChange[ecosystem], 116 | ...overwrites 117 | } 118 | } 119 | 120 | const createTestPURLs = (list: string[]): PackageURL[] => { 121 | return list.map(purl => { 122 | return parsePURL(purl) 123 | }) 124 | } 125 | 126 | export {createTestChange, createTestPURLs} 127 | -------------------------------------------------------------------------------- /__tests__/fixtures/create-test-vulnerability.ts: -------------------------------------------------------------------------------- 1 | import {Change} from '../../src/schemas' 2 | 3 | type Vulnerability = Change['vulnerabilities'][0] 4 | 5 | const defaultTestVulnerability: Vulnerability = { 6 | severity: 'high', 7 | advisory_ghsa_id: 'GHSA-35jh-r3h4-6jhm', 8 | advisory_summary: 'Command Injection in lodash', 9 | advisory_url: 'https://github.com/advisories/GHSA-35jh-r3h4-6jhm' 10 | } 11 | 12 | const createTestVulnerability = ( 13 | overwrites: Partial = {} 14 | ): Vulnerability => ({ 15 | ...defaultTestVulnerability, 16 | ...overwrites 17 | }) 18 | 19 | export {createTestVulnerability} 20 | -------------------------------------------------------------------------------- /__tests__/fixtures/inline-license-config-sample.yml: -------------------------------------------------------------------------------- 1 | allow-licenses: 'MIT, GPL-2.0-only' 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/invalid-severity-config.yml: -------------------------------------------------------------------------------- 1 | fail_on_severity: 'so many zombies' 2 | deny_licenses: 3 | - MIT 4 | -------------------------------------------------------------------------------- /__tests__/fixtures/license-config-sample.yml: -------------------------------------------------------------------------------- 1 | allow_licenses: ['MIT', 'GPL 2'] 2 | -------------------------------------------------------------------------------- /__tests__/fixtures/no-licenses-config.yml: -------------------------------------------------------------------------------- 1 | fail_on_severity: critical 2 | -------------------------------------------------------------------------------- /__tests__/licenses.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, jest, test} from '@jest/globals' 2 | import {Change, Changes} from '../src/schemas' 3 | import {getInvalidLicenseChanges} from '../src/licenses' 4 | 5 | const npmChange: Change = { 6 | manifest: 'package.json', 7 | change_type: 'added', 8 | ecosystem: 'npm', 9 | name: 'Reeuhq', 10 | version: '1.0.2', 11 | package_url: 'pkg:npm/reeuhq@1.0.2', 12 | license: 'MIT', 13 | source_repository_url: 'github.com/some-repo', 14 | scope: 'runtime', 15 | vulnerabilities: [ 16 | { 17 | severity: 'critical', 18 | advisory_ghsa_id: 'first-random_string', 19 | advisory_summary: 'very dangerous', 20 | advisory_url: 'github.com/future-funk' 21 | } 22 | ] 23 | } 24 | 25 | const rubyChange: Change = { 26 | change_type: 'added', 27 | manifest: 'Gemfile.lock', 28 | ecosystem: 'rubygems', 29 | name: 'actionsomething', 30 | version: '3.2.0', 31 | package_url: 'pkg:gem/actionsomething@3.2.0', 32 | license: 'BSD-3-Clause', 33 | source_repository_url: 'github.com/some-repo', 34 | scope: 'runtime', 35 | vulnerabilities: [ 36 | { 37 | severity: 'moderate', 38 | advisory_ghsa_id: 'second-random_string', 39 | advisory_summary: 'not so dangerous', 40 | advisory_url: 'github.com/future-funk' 41 | }, 42 | { 43 | severity: 'low', 44 | advisory_ghsa_id: 'third-random_string', 45 | advisory_summary: 'dont page me', 46 | advisory_url: 'github.com/future-funk' 47 | } 48 | ] 49 | } 50 | 51 | const pipChange: Change = { 52 | change_type: 'added', 53 | manifest: 'requirements.txt', 54 | ecosystem: 'pip', 55 | name: 'package-1', 56 | version: '1.1.1', 57 | package_url: 'pkg:pypi/package-1@1.1.1', 58 | license: 'MIT', 59 | source_repository_url: 'github.com/some-repo', 60 | scope: 'runtime', 61 | vulnerabilities: [ 62 | { 63 | severity: 'moderate', 64 | advisory_ghsa_id: 'second-random_string', 65 | advisory_summary: 'not so dangerous', 66 | advisory_url: 'github.com/future-funk' 67 | }, 68 | { 69 | severity: 'low', 70 | advisory_ghsa_id: 'third-random_string', 71 | advisory_summary: 'dont page me', 72 | advisory_url: 'github.com/future-funk' 73 | } 74 | ] 75 | } 76 | 77 | const complexLicenseChange: Change = { 78 | change_type: 'added', 79 | manifest: 'requirements.txt', 80 | ecosystem: 'pip', 81 | name: 'package-1', 82 | version: '1.1.1', 83 | package_url: 'pkg:pypi/package-1@1.1.1', 84 | license: 'MIT AND Apache-2.0', 85 | source_repository_url: 'github.com/some-repo', 86 | scope: 'runtime', 87 | vulnerabilities: [ 88 | { 89 | severity: 'moderate', 90 | advisory_ghsa_id: 'second-random_string', 91 | advisory_summary: 'not so dangerous', 92 | advisory_url: 'github.com/future-funk' 93 | }, 94 | { 95 | severity: 'low', 96 | advisory_ghsa_id: 'third-random_string', 97 | advisory_summary: 'dont page me', 98 | advisory_url: 'github.com/future-funk' 99 | } 100 | ] 101 | } 102 | 103 | const unlicensedChange: Change = { 104 | change_type: 'added', 105 | manifest: '.github/workflows/ci.yml', 106 | ecosystem: 'actions', 107 | name: 'foo-org/actions-repo/.github/workflows/some-action.yml', 108 | version: '1.1.1', 109 | package_url: 110 | 'pkg:githubactions/foo-org/actions-repo/.github/workflows/some-action.yml@1.1.1', 111 | license: null, 112 | source_repository_url: 'github.com/some-repo', 113 | scope: 'development', 114 | vulnerabilities: [] 115 | } 116 | 117 | jest.mock('@actions/core') 118 | 119 | const mockOctokit = { 120 | rest: { 121 | licenses: { 122 | getForRepo: jest 123 | .fn() 124 | .mockReturnValue({data: {license: {spdx_id: 'AGPL'}}}) 125 | } 126 | } 127 | } 128 | 129 | jest.mock('octokit', () => { 130 | return { 131 | // eslint-disable-next-line @typescript-eslint/no-extraneous-class 132 | Octokit: class { 133 | constructor() { 134 | return mockOctokit 135 | } 136 | } 137 | } 138 | }) 139 | 140 | beforeEach(async () => { 141 | jest.resetModules() 142 | }) 143 | 144 | test('it adds license outside the allow list to forbidden changes', async () => { 145 | const changes: Changes = [ 146 | npmChange, // MIT license 147 | rubyChange // BSD license 148 | ] 149 | 150 | const {forbidden} = await getInvalidLicenseChanges(changes, { 151 | allow: ['BSD-3-Clause'] 152 | }) 153 | 154 | expect(forbidden[0]).toBe(npmChange) 155 | expect(forbidden.length).toEqual(1) 156 | }) 157 | 158 | test('it adds license inside the deny list to forbidden changes', async () => { 159 | const changes: Changes = [ 160 | npmChange, // MIT license 161 | rubyChange // BSD license 162 | ] 163 | 164 | const {forbidden} = await getInvalidLicenseChanges(changes, { 165 | deny: ['BSD-3-Clause'] 166 | }) 167 | 168 | expect(forbidden[0]).toBe(rubyChange) 169 | expect(forbidden.length).toEqual(1) 170 | }) 171 | 172 | test('it handles allowed complex licenses', async () => { 173 | const changes: Changes = [ 174 | complexLicenseChange // MIT AND Apache-2.0 license 175 | ] 176 | 177 | const {forbidden} = await getInvalidLicenseChanges(changes, { 178 | allow: ['MIT', 'Apache-2.0'] 179 | }) 180 | 181 | expect(forbidden.length).toEqual(0) 182 | }) 183 | 184 | test('it handles complex licenses not all on the allow list', async () => { 185 | const changes: Changes = [ 186 | complexLicenseChange // MIT AND Apache-2.0 license 187 | ] 188 | 189 | const {forbidden} = await getInvalidLicenseChanges(changes, { 190 | allow: ['MIT'] 191 | }) 192 | 193 | expect(forbidden.length).toEqual(1) 194 | }) 195 | 196 | test('it does not add license outside the allow list to forbidden changes if it is in removed changes', async () => { 197 | const changes: Changes = [ 198 | {...npmChange, change_type: 'removed'}, 199 | {...rubyChange, change_type: 'removed'} 200 | ] 201 | const {forbidden} = await getInvalidLicenseChanges(changes, { 202 | allow: ['BSD-3-Clause'] 203 | }) 204 | expect(forbidden).toStrictEqual([]) 205 | }) 206 | 207 | test('it does not add license inside the deny list to forbidden changes if it is in removed changes', async () => { 208 | const changes: Changes = [ 209 | {...npmChange, change_type: 'removed'}, 210 | {...rubyChange, change_type: 'removed'} 211 | ] 212 | const {forbidden} = await getInvalidLicenseChanges(changes, { 213 | deny: ['BSD-3-Clause'] 214 | }) 215 | expect(forbidden).toStrictEqual([]) 216 | }) 217 | 218 | test('it adds license outside the allow list to forbidden changes if it is in both added and removed changes', async () => { 219 | const changes: Changes = [ 220 | {...npmChange, change_type: 'removed'}, 221 | npmChange, 222 | {...rubyChange, change_type: 'removed'} 223 | ] 224 | const {forbidden} = await getInvalidLicenseChanges(changes, { 225 | allow: ['BSD-3-Clause'] 226 | }) 227 | expect(forbidden).toStrictEqual([npmChange]) 228 | }) 229 | 230 | test('it adds all licenses to unresolved if it is unable to determine the validity', async () => { 231 | const changes: Changes = [ 232 | {...npmChange, license: 'Foo'}, 233 | {...rubyChange, license: 'Bar'} 234 | ] 235 | const invalidLicenses = await getInvalidLicenseChanges(changes, { 236 | allow: ['Apache-2.0'] 237 | }) 238 | expect(invalidLicenses.forbidden.length).toEqual(0) 239 | expect(invalidLicenses.unlicensed.length).toEqual(0) 240 | expect(invalidLicenses.unresolved.length).toEqual(2) 241 | }) 242 | 243 | test('it does not filter out changes that are on the exclusions list', async () => { 244 | const changes: Changes = [pipChange, npmChange, rubyChange] 245 | const licensesConfig = { 246 | allow: ['BSD-3-Clause'], 247 | licenseExclusions: ['pkg:pypi/package-1@1.1.1', 'pkg:npm/reeuhq@1.0.2'] 248 | } 249 | const invalidLicenses = await getInvalidLicenseChanges( 250 | changes, 251 | licensesConfig 252 | ) 253 | expect(invalidLicenses.forbidden.length).toEqual(0) 254 | }) 255 | 256 | test('it does not fail when the packages dont have a valid PURL', async () => { 257 | const emptyPurlChange = pipChange 258 | emptyPurlChange.package_url = '' 259 | 260 | const changes: Changes = [emptyPurlChange, npmChange, rubyChange] 261 | const licensesConfig = { 262 | allow: ['BSD-3-Clause'], 263 | licenseExclusions: ['pkg:pypi/package-1@1.1.1', 'pkg:npm/reeuhq@1.0.2'] 264 | } 265 | 266 | const invalidLicenses = await getInvalidLicenseChanges( 267 | changes, 268 | licensesConfig 269 | ) 270 | expect(invalidLicenses.forbidden.length).toEqual(1) 271 | }) 272 | 273 | test('it does filters out changes if they are not on the exclusions list', async () => { 274 | const changes: Changes = [pipChange, npmChange, rubyChange] 275 | const licensesConfig = { 276 | allow: ['BSD-3-Clause'], 277 | licenseExclusions: [ 278 | 'pkg:pypi/notmypackage-1@1.1.1', 279 | 'pkg:npm/alsonot@1.0.2' 280 | ] 281 | } 282 | 283 | const invalidLicenses = await getInvalidLicenseChanges( 284 | changes, 285 | licensesConfig 286 | ) 287 | 288 | expect(invalidLicenses.forbidden.length).toEqual(2) 289 | expect(invalidLicenses.forbidden[0]).toBe(pipChange) 290 | expect(invalidLicenses.forbidden[1]).toBe(npmChange) 291 | }) 292 | 293 | test('it does not fail if there is a license expression in the allow list', async () => { 294 | const changes: Changes = [ 295 | {...npmChange, license: 'MIT AND Apache-2.0'}, 296 | {...rubyChange, license: 'BSD-3-Clause'} 297 | ] 298 | 299 | const {forbidden} = await getInvalidLicenseChanges(changes, { 300 | allow: ['BSD-3-Clause', 'MIT AND Apache-2.0', 'MIT', 'Apache-2.0'] 301 | }) 302 | 303 | expect(forbidden.length).toEqual(0) 304 | }) 305 | 306 | describe('GH License API fallback', () => { 307 | test('it calls licenses endpoint if atleast one of the changes has null license and valid source_repository_url', async () => { 308 | const nullLicenseChange = { 309 | ...npmChange, 310 | license: null, 311 | source_repository_url: 'http://github.com/some-owner/some-repo' 312 | } 313 | const {unlicensed} = await getInvalidLicenseChanges( 314 | [nullLicenseChange, rubyChange], 315 | {} 316 | ) 317 | 318 | expect(mockOctokit.rest.licenses.getForRepo).toHaveBeenNthCalledWith(1, { 319 | owner: 'some-owner', 320 | repo: 'some-repo' 321 | }) 322 | expect(unlicensed.length).toEqual(0) 323 | }) 324 | 325 | test('it does not call licenses API endpoint for change with null license and invalid source_repository_url ', async () => { 326 | const {unlicensed} = await getInvalidLicenseChanges( 327 | [{...npmChange, license: null}], 328 | {} 329 | ) 330 | expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled() 331 | expect(unlicensed.length).toEqual(1) 332 | }) 333 | 334 | test('it does not call licenses API endpoint if licenses for all changes are present', async () => { 335 | const {unlicensed} = await getInvalidLicenseChanges( 336 | [npmChange, rubyChange], 337 | {} 338 | ) 339 | 340 | expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled() 341 | expect(unlicensed.length).toEqual(0) 342 | }) 343 | 344 | test('it does not call licenses API if the package is excluded', async () => { 345 | const {unlicensed} = await getInvalidLicenseChanges([unlicensedChange], { 346 | licenseExclusions: [ 347 | 'pkg:githubactions/foo-org/actions-repo/.github/workflows/some-action.yml' 348 | ] 349 | }) 350 | 351 | expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled() 352 | expect(unlicensed.length).toEqual(0) 353 | }) 354 | 355 | test('it checks namespaces when doing exclusions', async () => { 356 | const {unlicensed} = await getInvalidLicenseChanges([unlicensedChange], { 357 | licenseExclusions: [ 358 | 'pkg:githubactions/bar-org/actions-repo/.github/workflows/some-action.yml' 359 | ] 360 | }) 361 | 362 | expect(mockOctokit.rest.licenses.getForRepo).not.toHaveBeenCalled() 363 | expect(unlicensed.length).toEqual(1) 364 | }) 365 | }) 366 | -------------------------------------------------------------------------------- /__tests__/purl.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@jest/globals' 2 | import {parsePURL} from '../src/purl' 3 | 4 | test('parsePURL returns an error if the purl does not start with "pkg:"', () => { 5 | const purl = 'not-a-purl' 6 | const result = parsePURL(purl) 7 | expect(result.error).toEqual('package-url must start with "pkg:"') 8 | }) 9 | 10 | test('parsePURL returns an error if the purl does not contain a type', () => { 11 | const purl = 'pkg:/' 12 | const result = parsePURL(purl) 13 | expect(result.error).toEqual('package-url must contain a type') 14 | }) 15 | 16 | test('parsePURL returns an error if the purl does not contain a namespace or name', () => { 17 | const purl = 'pkg:ecosystem/' 18 | const result = parsePURL(purl) 19 | expect(result.type).toEqual('ecosystem') 20 | expect(result.error).toEqual('package-url must contain a namespace or name') 21 | }) 22 | 23 | test('parsePURL returns a PURL with the correct values in the happy case', () => { 24 | const purl = 'pkg:ecosystem/namespace/name@version' 25 | const result = parsePURL(purl) 26 | expect(result.type).toEqual('ecosystem') 27 | expect(result.namespace).toEqual('namespace') 28 | expect(result.name).toEqual('name') 29 | expect(result.version).toEqual('version') 30 | expect(result.original).toEqual(purl) 31 | expect(result.error).toBeNull() 32 | }) 33 | 34 | test('parsePURL table test', () => { 35 | const examples = [ 36 | { 37 | purl: 'pkg:npm/@n4m3SPACE/Name@^1.2.3', 38 | expected: { 39 | type: 'npm', 40 | namespace: '@n4m3SPACE', 41 | name: 'Name', 42 | version: '^1.2.3', 43 | original: 'pkg:npm/@n4m3SPACE/Name@^1.2.3', 44 | error: null 45 | } 46 | }, 47 | { 48 | purl: 'pkg:golang/gopkg.in/DataDog/dd-trace-go.v1@1.63.1', 49 | // Note: this purl is technically invalid, but we can still parse it 50 | expected: { 51 | type: 'golang', 52 | namespace: 'gopkg.in', 53 | name: 'DataDog/dd-trace-go.v1', 54 | version: '1.63.1', 55 | original: 'pkg:golang/gopkg.in/DataDog/dd-trace-go.v1@1.63.1', 56 | error: null 57 | } 58 | }, 59 | { 60 | purl: 'pkg:golang/github.com/pelletier/go-toml/v2', 61 | // Note: this purl is technically invalid, but we can still parse it 62 | expected: { 63 | type: 'golang', 64 | namespace: 'github.com', 65 | name: 'pelletier/go-toml/v2', 66 | version: null, 67 | original: 'pkg:golang/github.com/pelletier/go-toml/v2', 68 | error: null 69 | } 70 | }, 71 | { 72 | purl: 'pkg:npm/%40ns%20foo/n%40me@1.%2f2.3', 73 | expected: { 74 | type: 'npm', 75 | namespace: '@ns foo', 76 | name: 'n@me', 77 | version: '1./2.3', 78 | original: 'pkg:npm/%40ns%20foo/n%40me@1.%2f2.3', 79 | error: null 80 | } 81 | }, 82 | { 83 | purl: 'pkg:ecosystem/name@version', 84 | expected: { 85 | type: 'ecosystem', 86 | namespace: null, 87 | name: 'name', 88 | version: 'version', 89 | original: 'pkg:ecosystem/name@version', 90 | error: null 91 | } 92 | }, 93 | { 94 | purl: 'pkg:npm/namespace/', 95 | expected: { 96 | type: 'npm', 97 | namespace: 'namespace', 98 | name: null, 99 | version: null, 100 | original: 'pkg:npm/namespace/', 101 | error: null 102 | } 103 | }, 104 | { 105 | purl: 'pkg:ecosystem/name', 106 | expected: { 107 | type: 'ecosystem', 108 | namespace: null, 109 | name: 'name', 110 | version: null, 111 | original: 'pkg:ecosystem/name', 112 | error: null 113 | } 114 | }, 115 | { 116 | purl: 'pkg:/?', 117 | expected: { 118 | type: '', 119 | namespace: null, 120 | name: null, 121 | version: null, 122 | original: 'pkg:/?', 123 | error: 'package-url must contain a type' 124 | } 125 | }, 126 | { 127 | purl: 'pkg:ecosystem/#', 128 | expected: { 129 | type: 'ecosystem', 130 | namespace: null, 131 | name: null, 132 | version: null, 133 | original: 'pkg:ecosystem/#', 134 | error: 'package-url must contain a namespace or name' 135 | } 136 | }, 137 | { 138 | purl: 'pkg:ecosystem/name@version#subpath?attributes=123', 139 | expected: { 140 | type: 'ecosystem', 141 | namespace: null, 142 | name: 'name', 143 | version: 'version', 144 | original: 'pkg:ecosystem/name@version#subpath?attributes=123', 145 | error: null 146 | } 147 | }, 148 | { 149 | purl: 'pkg:ecosystem/name@version#subpath', 150 | expected: { 151 | type: 'ecosystem', 152 | namespace: null, 153 | name: 'name', 154 | version: 'version', 155 | original: 'pkg:ecosystem/name@version#subpath', 156 | error: null 157 | } 158 | }, 159 | { 160 | purl: 'pkg:ecosystem/namespace/name@version?attributes', 161 | expected: { 162 | type: 'ecosystem', 163 | namespace: 'namespace', 164 | name: 'name', 165 | version: 'version', 166 | original: 'pkg:ecosystem/namespace/name@version?attributes', 167 | error: null 168 | } 169 | }, 170 | { 171 | purl: 'pkg:ecosystem/name#subpath?attributes', 172 | expected: { 173 | type: 'ecosystem', 174 | namespace: null, 175 | name: 'name', 176 | version: null, 177 | original: 'pkg:ecosystem/name#subpath?attributes', 178 | error: null 179 | } 180 | } 181 | ] 182 | for (const example of examples) { 183 | const result = parsePURL(example.purl) 184 | expect(result).toEqual(example.expected) 185 | } 186 | }) 187 | -------------------------------------------------------------------------------- /__tests__/scorecard.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test} from '@jest/globals' 2 | import {Change, Changes} from '../src/schemas' 3 | import {getScorecardLevels, getProjectUrl} from '../src/scorecard' 4 | 5 | const npmChange: Change = { 6 | manifest: 'package.json', 7 | change_type: 'added', 8 | ecosystem: 'npm', 9 | name: 'type-is', 10 | version: '1.6.18', 11 | package_url: 'pkg:npm/type-is@1.6.18', 12 | license: 'MIT', 13 | source_repository_url: 'github.com/jshttp/type-is', 14 | scope: 'runtime', 15 | vulnerabilities: [ 16 | { 17 | severity: 'critical', 18 | advisory_ghsa_id: 'first-random_string', 19 | advisory_summary: 'very dangerous', 20 | advisory_url: 'github.com/future-funk' 21 | } 22 | ] 23 | } 24 | 25 | const actionsChange: Change = { 26 | manifest: 'workflow.yml', 27 | change_type: 'added', 28 | ecosystem: 'actions', 29 | name: 'actions/checkout/', 30 | version: 'v3', 31 | package_url: 'pkg:githubactions/actions@v3', 32 | license: 'MIT', 33 | source_repository_url: 'null', 34 | scope: 'runtime', 35 | vulnerabilities: [] 36 | } 37 | 38 | test('Get scorecard from API', async () => { 39 | const changes: Changes = [npmChange] 40 | const scorecard = await getScorecardLevels(changes) 41 | expect(scorecard).not.toBeNull() 42 | expect(scorecard.dependencies).toHaveLength(1) 43 | expect(scorecard.dependencies[0].scorecard?.score).toBeGreaterThan(0) 44 | }) 45 | 46 | test('Get project URL from deps.dev API', async () => { 47 | const result = await getProjectUrl( 48 | npmChange.ecosystem, 49 | npmChange.name, 50 | npmChange.version 51 | ) 52 | expect(result).not.toBeNull() 53 | }) 54 | 55 | test('Handles Actions special case', async () => { 56 | const changes: Changes = [actionsChange] 57 | const result = await getScorecardLevels(changes) 58 | expect(result).not.toBeNull() 59 | expect(result.dependencies).toHaveLength(1) 60 | expect(result.dependencies[0].scorecard?.score).toBeGreaterThan(0) 61 | }) 62 | -------------------------------------------------------------------------------- /__tests__/spdx.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, test, describe} from '@jest/globals' 2 | import * as spdx from '../src/spdx' 3 | 4 | describe('satisfiesAny', () => { 5 | const units = [ 6 | { 7 | candidate: 'MIT', 8 | licenses: ['MIT'], 9 | expected: true 10 | }, 11 | { 12 | candidate: 'MIT OR Apache-2.0', 13 | licenses: ['MIT', 'Apache-2.0'], 14 | expected: true 15 | }, 16 | { 17 | candidate: '(MIT AND ISC) OR Apache-2.0', 18 | licenses: ['MIT', 'Apache-2.0'], 19 | expected: true 20 | }, 21 | { 22 | candidate: 'MIT AND Apache-2.0', 23 | licenses: ['MIT', 'Apache-2.0'], 24 | expected: false 25 | }, 26 | { 27 | candidate: 'MIT AND BSD-3-Clause', 28 | licenses: ['MIT', 'Apache-2.0'], 29 | expected: false 30 | }, 31 | 32 | // missing params, case sensitivity, syntax problems, 33 | // or unknown licenses will return 'false' 34 | { 35 | candidate: 'MIT OR', 36 | licenses: ['MIT', 'Apache-2.0'], 37 | expected: false 38 | }, 39 | { 40 | candidate: '', 41 | licenses: ['MIT', 'Apache-2.0'], 42 | expected: false 43 | }, 44 | { 45 | candidate: 'MIT OR (Apache-2.0 AND ISC)', 46 | licenses: [], 47 | expected: false 48 | }, 49 | { 50 | candidate: 'MIT AND (ISC', 51 | licenses: ['MIT', 'Apache-2.0'], 52 | expected: false 53 | }, 54 | { 55 | candidate: 'MIT OR ISC', 56 | licenses: ['MiT'], 57 | expected: false 58 | }, 59 | { 60 | candidate: 'MIT AND OTHER', 61 | licenses: ['MIT'], 62 | expected: false 63 | }, 64 | { 65 | candidate: 'MIT OR OTHER', 66 | licenses: ['MIT', 'LicenseRef-clearlydefined-OTHER'], 67 | expected: true 68 | } 69 | ] 70 | 71 | for (const unit of units) { 72 | const got: boolean = spdx.satisfiesAny(unit.candidate, unit.licenses) 73 | test(`should return ${unit.expected} for ("${unit.candidate}", "${unit.licenses}")`, () => { 74 | expect(got).toBe(unit.expected) 75 | }) 76 | } 77 | }) 78 | 79 | describe('satisfiesAll', () => { 80 | const units = [ 81 | { 82 | candidate: 'MIT', 83 | licenses: ['MIT'], 84 | expected: true 85 | }, 86 | { 87 | candidate: 'Apache-2.0', 88 | licenses: ['MIT', 'ISC', 'Apache-2.0'], 89 | expected: false 90 | }, 91 | { 92 | candidate: 'MIT AND Apache-2.0', 93 | licenses: ['MIT', 'Apache-2.0'], 94 | expected: true 95 | }, 96 | { 97 | candidate: '(MIT OR ISC) AND Apache-2.0', 98 | licenses: ['MIT', 'Apache-2.0'], 99 | expected: true 100 | }, 101 | { 102 | candidate: 'MIT OR BSD-3-Clause', 103 | licenses: ['MIT', 'Apache-2.0'], 104 | expected: false 105 | }, 106 | { 107 | candidate: 'BSD-3-Clause OR ISC', 108 | licenses: ['MIT', 'Apache-2.0'], 109 | expected: false 110 | }, 111 | { 112 | candidate: '(MIT AND ISC) OR Apache-2.0', 113 | licenses: ['MIT', 'ISC'], 114 | expected: true 115 | }, 116 | 117 | // missing params, case sensitivity, syntax problems, 118 | // or unknown licenses will return 'false' 119 | { 120 | candidate: 'MIT OR', 121 | licenses: ['MIT', 'Apache-2.0'], 122 | expected: false 123 | }, 124 | { 125 | candidate: '', 126 | licenses: ['MIT', 'Apache-2.0'], 127 | expected: false 128 | }, 129 | { 130 | candidate: 'MIT OR (Apache-2.0 AND ISC)', 131 | licenses: [], 132 | expected: false 133 | }, 134 | { 135 | candidate: 'MIT AND (ISC', 136 | licenses: ['MIT', 'Apache-2.0'], 137 | expected: false 138 | }, 139 | { 140 | candidate: 'MIT OR ISC', 141 | licenses: ['MiT'], 142 | expected: false 143 | }, 144 | { 145 | candidate: 'MIT AND OTHER', 146 | licenses: ['MIT'], 147 | expected: false 148 | }, 149 | { 150 | candidate: 'MIT AND OTHER', 151 | licenses: ['MIT', 'LicenseRef-clearlydefined-OTHER'], 152 | expected: true 153 | } 154 | ] 155 | 156 | for (const unit of units) { 157 | const got: boolean = spdx.satisfiesAll(unit.candidate, unit.licenses) 158 | test(`should return ${unit.expected} for ("${unit.candidate}", "${unit.licenses}")`, () => { 159 | expect(got).toBe(unit.expected) 160 | }) 161 | } 162 | }) 163 | 164 | describe('satisfies', () => { 165 | const units = [ 166 | { 167 | candidate: 'MIT', 168 | allowList: ['MIT'], 169 | expected: true 170 | }, 171 | { 172 | candidate: 'Apache-2.0', 173 | allowList: ['MIT'], 174 | expected: false 175 | }, 176 | { 177 | candidate: 'MIT OR Apache-2.0', 178 | allowList: ['MIT'], 179 | expected: true 180 | }, 181 | { 182 | candidate: 'MIT OR Apache-2.0', 183 | allowList: ['Apache-2.0'], 184 | expected: true 185 | }, 186 | { 187 | candidate: 'MIT OR Apache-2.0', 188 | allowList: ['BSD-3-Clause'], 189 | expected: false 190 | }, 191 | { 192 | candidate: 'MIT OR Apache-2.0', 193 | allowList: ['Apache-2.0', 'BSD-3-Clause'], 194 | expected: true 195 | }, 196 | { 197 | candidate: 'MIT AND Apache-2.0', 198 | allowList: ['MIT', 'Apache-2.0'], 199 | expected: true 200 | }, 201 | { 202 | candidate: 'MIT OR Apache-2.0', 203 | allowList: ['MIT', 'Apache-2.0'], 204 | expected: true 205 | }, 206 | { 207 | candidate: 'ISC OR (MIT AND Apache-2.0)', 208 | allowList: ['MIT', 'Apache-2.0'], 209 | expected: true 210 | }, 211 | 212 | // missing params, case sensitivity, syntax problems, 213 | // or unknown licenses will return 'false' 214 | { 215 | candidate: 'MIT', 216 | allowList: ['MiT'], 217 | expected: false 218 | }, 219 | { 220 | candidate: 'MIT AND (ISC OR', 221 | allowList: ['MIT'], 222 | expected: false 223 | }, 224 | { 225 | candidate: 'MIT OR ISC OR Apache-2.0', 226 | allowList: [], 227 | expected: false 228 | }, 229 | { 230 | candidate: '', 231 | allowList: ['BSD-3-Clause', 'ISC', 'MIT'], 232 | expected: false 233 | }, 234 | { 235 | candidate: 'MIT OR OTHER', 236 | allowList: ['MIT', 'LicenseRef-clearlydefined-OTHER'], 237 | expected: true 238 | }, 239 | { 240 | candidate: '(Apache-2.0 AND OTHER) OR (MIT AND OTHER)', 241 | allowList: ['Apache-2.0', 'LicenseRef-clearlydefined-OTHER'], 242 | expected: true 243 | } 244 | ] 245 | 246 | for (const unit of units) { 247 | const got: boolean = spdx.satisfies(unit.candidate, unit.allowList) 248 | test(`should return ${unit.expected} for ("${unit.candidate}", "${unit.allowList}")`, () => { 249 | expect(got).toBe(unit.expected) 250 | }) 251 | } 252 | }) 253 | 254 | describe('isValid', () => { 255 | const units = [ 256 | { 257 | candidate: 'MIT', 258 | expected: true 259 | }, 260 | { 261 | candidate: 'MIT AND BSD-3-Clause', 262 | expected: true 263 | }, 264 | { 265 | candidate: '(MIT AND ISC) OR BSD-3-Clause', 266 | expected: true 267 | }, 268 | { 269 | candidate: 'NOASSERTION', 270 | expected: false 271 | }, 272 | { 273 | candidate: 'Foobar', 274 | expected: false 275 | }, 276 | { 277 | candidate: '', 278 | expected: false 279 | }, 280 | { 281 | candidate: 'MIT AND OTHER', 282 | expected: true 283 | } 284 | ] 285 | for (const unit of units) { 286 | const got: boolean = spdx.isValid(unit.candidate) 287 | test(`should return ${unit.expected} for ("${unit.candidate}")`, () => { 288 | expect(got).toBe(unit.expected) 289 | }) 290 | } 291 | }) 292 | 293 | describe('cleanInvalidSPDX', () => { 294 | const units = [ 295 | { 296 | candidate: 'MIT', 297 | expected: 'MIT' 298 | }, 299 | { 300 | candidate: 'OTHER', 301 | expected: 'LicenseRef-clearlydefined-OTHER' 302 | }, 303 | { 304 | candidate: 'LicenseRef-clearlydefined-OTHER', 305 | expected: 'LicenseRef-clearlydefined-OTHER' 306 | }, 307 | { 308 | candidate: 'OTHER AND MIT', 309 | expected: 'LicenseRef-clearlydefined-OTHER AND MIT' 310 | }, 311 | { 312 | candidate: 'MIT AND OTHER', 313 | expected: 'MIT AND LicenseRef-clearlydefined-OTHER' 314 | }, 315 | { 316 | candidate: 'MIT AND SomethingElse-OTHER', 317 | expected: 'MIT AND SomethingElse-OTHER' 318 | } 319 | ] 320 | for (const unit of units) { 321 | const got: string = spdx.cleanInvalidSPDX(unit.candidate) 322 | test(`should return ${unit.expected} for ("${unit.candidate}")`, () => { 323 | expect(got).toBe(unit.expected) 324 | }) 325 | } 326 | }) 327 | -------------------------------------------------------------------------------- /__tests__/summary.test.ts: -------------------------------------------------------------------------------- 1 | import {expect, jest, test} from '@jest/globals' 2 | import {Change, Changes, ConfigurationOptions, Scorecard} from '../src/schemas' 3 | import * as summary from '../src/summary' 4 | import * as core from '@actions/core' 5 | import {createTestChange} from './fixtures/create-test-change' 6 | import {createTestVulnerability} from './fixtures/create-test-vulnerability' 7 | 8 | afterEach(() => { 9 | jest.clearAllMocks() 10 | core.summary.emptyBuffer() 11 | }) 12 | 13 | const emptyChanges: Changes = [] 14 | const emptyInvalidLicenseChanges = { 15 | forbidden: [], 16 | unresolved: [], 17 | unlicensed: [] 18 | } 19 | const emptyScorecard: Scorecard = { 20 | dependencies: [] 21 | } 22 | const defaultConfig: ConfigurationOptions = { 23 | vulnerability_check: true, 24 | license_check: true, 25 | fail_on_severity: 'high', 26 | fail_on_scopes: ['runtime'], 27 | allow_ghsas: [], 28 | allow_licenses: [], 29 | deny_licenses: [], 30 | deny_packages: [], 31 | deny_groups: [], 32 | comment_summary_in_pr: true, 33 | retry_on_snapshot_warnings: false, 34 | retry_on_snapshot_warnings_timeout: 120, 35 | warn_only: false, 36 | warn_on_openssf_scorecard_level: 3, 37 | show_openssf_scorecard: false 38 | } 39 | 40 | const changesWithEmptyManifests: Changes = [ 41 | { 42 | change_type: 'added', 43 | manifest: '', 44 | ecosystem: 'unknown', 45 | name: 'castore', 46 | version: '0.1.17', 47 | package_url: 'pkg:hex/castore@0.1.17', 48 | license: null, 49 | source_repository_url: null, 50 | scope: 'runtime', 51 | vulnerabilities: [] 52 | }, 53 | { 54 | change_type: 'added', 55 | manifest: '', 56 | ecosystem: 'unknown', 57 | name: 'connection', 58 | version: '1.1.0', 59 | package_url: 'pkg:hex/connection@1.1.0', 60 | license: null, 61 | source_repository_url: null, 62 | scope: 'runtime', 63 | vulnerabilities: [] 64 | }, 65 | { 66 | change_type: 'added', 67 | manifest: 'python/dist-info/METADATA', 68 | ecosystem: 'pip', 69 | name: 'pygments', 70 | version: '2.6.1', 71 | package_url: 'pkg:pypi/pygments@2.6.1', 72 | license: 'BSD-2-Clause', 73 | source_repository_url: 'https://github.com/pygments/pygments', 74 | scope: 'runtime', 75 | vulnerabilities: [] 76 | } 77 | ] 78 | 79 | const scorecard: Scorecard = { 80 | dependencies: [ 81 | { 82 | change: { 83 | change_type: 'added', 84 | manifest: '', 85 | ecosystem: 'unknown', 86 | name: 'castore', 87 | version: '0.1.17', 88 | package_url: 'pkg:hex/castore@0.1.17', 89 | license: null, 90 | source_repository_url: null, 91 | scope: 'runtime', 92 | vulnerabilities: [] 93 | }, 94 | scorecard: null 95 | } 96 | ] 97 | } 98 | 99 | test('prints headline as h1', () => { 100 | summary.addSummaryToSummary( 101 | emptyChanges, 102 | emptyInvalidLicenseChanges, 103 | emptyChanges, 104 | scorecard, 105 | defaultConfig 106 | ) 107 | const text = core.summary.stringify() 108 | 109 | expect(text).toContain('

Dependency Review

') 110 | }) 111 | 112 | test('returns minimal summary formatted for posting as a PR comment', () => { 113 | const OLD_ENV = process.env 114 | 115 | let changes: Changes = [ 116 | createTestChange({name: 'lodash', version: '1.2.3'}), 117 | createTestChange({name: 'colors', version: '2.3.4'}), 118 | createTestChange({name: '@foo/bar', version: '*'}) 119 | ] 120 | 121 | process.env.GITHUB_SERVER_URL = 'https://github.com' 122 | process.env.GITHUB_REPOSITORY = 'owner/repo' 123 | process.env.GITHUB_RUN_ID = 'abc-123-xyz' 124 | 125 | let minSummary: string = summary.addSummaryToSummary( 126 | changes, 127 | emptyInvalidLicenseChanges, 128 | emptyChanges, 129 | scorecard, 130 | defaultConfig 131 | ) 132 | 133 | process.env = OLD_ENV 134 | 135 | // note: no Actions context values in unit test env 136 | const expected = ` 137 | # Dependency Review 138 | The following issues were found: 139 | * ❌ 3 vulnerable package(s) 140 | * ✅ 0 package(s) with incompatible licenses 141 | * ✅ 0 package(s) with invalid SPDX license definitions 142 | * ✅ 0 package(s) with unknown licenses. 143 | 144 | [View full job summary](https://github.com/owner/repo/actions/runs/abc-123-xyz) 145 | `.trim() 146 | 147 | expect(minSummary).toEqual(expected) 148 | }) 149 | 150 | test('only includes "No vulnerabilities or license issues found"-message if both are configured and nothing was found', () => { 151 | summary.addSummaryToSummary( 152 | emptyChanges, 153 | emptyInvalidLicenseChanges, 154 | emptyChanges, 155 | emptyScorecard, 156 | defaultConfig 157 | ) 158 | const text = core.summary.stringify() 159 | 160 | expect(text).toContain('✅ No vulnerabilities or license issues found.') 161 | }) 162 | 163 | test('only includes "No vulnerabilities found"-message if "license_check" is set to false and nothing was found', () => { 164 | const config = {...defaultConfig, license_check: false} 165 | summary.addSummaryToSummary( 166 | emptyChanges, 167 | emptyInvalidLicenseChanges, 168 | emptyChanges, 169 | emptyScorecard, 170 | config 171 | ) 172 | const text = core.summary.stringify() 173 | 174 | expect(text).toContain('✅ No vulnerabilities found.') 175 | }) 176 | 177 | test('only includes "No license issues found"-message if "vulnerability_check" is set to false and nothing was found', () => { 178 | const config = {...defaultConfig, vulnerability_check: false} 179 | summary.addSummaryToSummary( 180 | emptyChanges, 181 | emptyInvalidLicenseChanges, 182 | emptyChanges, 183 | emptyScorecard, 184 | config 185 | ) 186 | const text = core.summary.stringify() 187 | 188 | expect(text).toContain('✅ No license issues found.') 189 | }) 190 | 191 | test('groups dependencies with empty manifest paths together', () => { 192 | summary.addSummaryToSummary( 193 | changesWithEmptyManifests, 194 | emptyInvalidLicenseChanges, 195 | emptyChanges, 196 | emptyScorecard, 197 | defaultConfig 198 | ) 199 | summary.addScannedFiles(changesWithEmptyManifests) 200 | const text = core.summary.stringify() 201 | expect(text).toContain('Unnamed Manifest') 202 | expect(text).toContain('python/dist-info/METADATA') 203 | }) 204 | 205 | test('does not include status section if nothing was found', () => { 206 | summary.addSummaryToSummary( 207 | emptyChanges, 208 | emptyInvalidLicenseChanges, 209 | emptyChanges, 210 | emptyScorecard, 211 | defaultConfig 212 | ) 213 | const text = core.summary.stringify() 214 | 215 | expect(text).not.toContain('The following issues were found:') 216 | }) 217 | 218 | test('includes count and status icons for all findings', () => { 219 | const vulnerabilities = [ 220 | createTestChange({name: 'lodash'}), 221 | createTestChange({name: 'underscore', package_url: 'test-url'}) 222 | ] 223 | const licenseIssues = { 224 | forbidden: [createTestChange()], 225 | unresolved: [createTestChange(), createTestChange()], 226 | unlicensed: [createTestChange(), createTestChange(), createTestChange()] 227 | } 228 | 229 | summary.addSummaryToSummary( 230 | vulnerabilities, 231 | licenseIssues, 232 | emptyChanges, 233 | emptyScorecard, 234 | defaultConfig 235 | ) 236 | 237 | const text = core.summary.stringify() 238 | expect(text).toContain('❌ 2 vulnerable package(s)') 239 | expect(text).toContain( 240 | '❌ 2 package(s) with invalid SPDX license definitions' 241 | ) 242 | expect(text).toContain('❌ 1 package(s) with incompatible licenses') 243 | expect(text).toContain('⚠️ 3 package(s) with unknown licenses') 244 | }) 245 | 246 | test('uses checkmarks for license issues if only vulnerabilities were found', () => { 247 | const vulnerabilities = [createTestChange()] 248 | 249 | summary.addSummaryToSummary( 250 | vulnerabilities, 251 | emptyInvalidLicenseChanges, 252 | emptyChanges, 253 | emptyScorecard, 254 | defaultConfig 255 | ) 256 | 257 | const text = core.summary.stringify() 258 | expect(text).toContain('❌ 1 vulnerable package(s)') 259 | expect(text).toContain( 260 | '✅ 0 package(s) with invalid SPDX license definitions' 261 | ) 262 | expect(text).toContain('✅ 0 package(s) with incompatible licenses') 263 | expect(text).toContain('✅ 0 package(s) with unknown licenses') 264 | }) 265 | 266 | test('uses checkmarks for vulnerabilities if only license issues were found', () => { 267 | const licenseIssues = { 268 | forbidden: [createTestChange()], 269 | unresolved: [], 270 | unlicensed: [] 271 | } 272 | 273 | summary.addSummaryToSummary( 274 | emptyChanges, 275 | licenseIssues, 276 | emptyChanges, 277 | emptyScorecard, 278 | defaultConfig 279 | ) 280 | 281 | const text = core.summary.stringify() 282 | expect(text).toContain('✅ 0 vulnerable package(s)') 283 | expect(text).toContain( 284 | '✅ 0 package(s) with invalid SPDX license definitions' 285 | ) 286 | expect(text).toContain('❌ 1 package(s) with incompatible licenses') 287 | expect(text).toContain('✅ 0 package(s) with unknown licenses') 288 | }) 289 | 290 | test('addChangeVulnerabilitiesToSummary() - only includes section if any vulnerabilites found', () => { 291 | summary.addChangeVulnerabilitiesToSummary(emptyChanges, 'low') 292 | const text = core.summary.stringify() 293 | expect(text).toEqual('') 294 | }) 295 | 296 | test('addChangeVulnerabilitiesToSummary() - includes all vulnerabilities', () => { 297 | const changes = [ 298 | createTestChange({name: 'lodash'}), 299 | createTestChange({name: 'underscore', package_url: 'test-url'}) 300 | ] 301 | 302 | summary.addChangeVulnerabilitiesToSummary(changes, 'low') 303 | 304 | const text = core.summary.stringify() 305 | expect(text).toContain('

Vulnerabilities

') 306 | expect(text).toContain('lodash') 307 | expect(text).toContain('underscore') 308 | }) 309 | 310 | test('addChangeVulnerabilitiesToSummary() - includes advisory url if available', () => { 311 | const changes = [ 312 | createTestChange({ 313 | name: 'underscore', 314 | vulnerabilities: [ 315 | createTestVulnerability({ 316 | advisory_summary: 'test-summary', 317 | advisory_url: 'test-url' 318 | }) 319 | ] 320 | }) 321 | ] 322 | 323 | summary.addChangeVulnerabilitiesToSummary(changes, 'low') 324 | 325 | const text = core.summary.stringify() 326 | expect(text).toContain('lodash') 327 | expect(text).toContain('test-summary') 328 | }) 329 | 330 | test('addChangeVulnerabilitiesToSummary() - groups vulnerabilities of a single package', () => { 331 | const changes = [ 332 | createTestChange({ 333 | name: 'package-with-multiple-vulnerabilities', 334 | vulnerabilities: [ 335 | createTestVulnerability({advisory_summary: 'test-summary-1'}), 336 | createTestVulnerability({advisory_summary: 'test-summary-2'}) 337 | ] 338 | }) 339 | ] 340 | 341 | summary.addChangeVulnerabilitiesToSummary(changes, 'low') 342 | 343 | const text = core.summary.stringify() 344 | expect(text.match('package-with-multiple-vulnerabilities')).toHaveLength(1) 345 | expect(text).toContain('test-summary-1') 346 | expect(text).toContain('test-summary-2') 347 | }) 348 | 349 | test('addChangeVulnerabilitiesToSummary() - prints severity statement if above low', () => { 350 | const changes = [createTestChange()] 351 | 352 | summary.addChangeVulnerabilitiesToSummary(changes, 'medium') 353 | 354 | const text = core.summary.stringify() 355 | expect(text).toContain( 356 | 'Only included vulnerabilities with severity medium or higher.' 357 | ) 358 | }) 359 | 360 | test('addChangeVulnerabilitiesToSummary() - does not print severity statment if it is set to "low"', () => { 361 | const changes = [createTestChange()] 362 | 363 | summary.addChangeVulnerabilitiesToSummary(changes, 'low') 364 | 365 | const text = core.summary.stringify() 366 | expect(text).not.toContain('Only included vulnerabilities') 367 | }) 368 | 369 | test('addLicensesToSummary() - does not include entire section if no license issues found', () => { 370 | summary.addLicensesToSummary(emptyInvalidLicenseChanges, defaultConfig) 371 | const text = core.summary.stringify() 372 | expect(text).toEqual('') 373 | }) 374 | 375 | test('addLicensesToSummary() - includes all license issues in table', () => { 376 | const licenseIssues = { 377 | forbidden: [createTestChange()], 378 | unresolved: [createTestChange(), createTestChange()], 379 | unlicensed: [createTestChange(), createTestChange(), createTestChange()] 380 | } 381 | 382 | summary.addLicensesToSummary(licenseIssues, defaultConfig) 383 | 384 | const text = core.summary.stringify() 385 | expect(text).toContain('

License Issues

') 386 | expect(text).toContain('Incompatible License') 387 | expect(text).toContain('Invalid SPDX License') 388 | expect(text).toContain('Unknown License') 389 | }) 390 | 391 | test('addLicenseToSummary() - adds one table per manifest', () => { 392 | const licenseIssues = { 393 | forbidden: [ 394 | createTestChange({manifest: 'package.json'}), 395 | createTestChange({manifest: '.github/workflows/test.yml'}) 396 | ], 397 | unresolved: [], 398 | unlicensed: [] 399 | } 400 | 401 | summary.addLicensesToSummary(licenseIssues, defaultConfig) 402 | 403 | const text = core.summary.stringify() 404 | 405 | expect(text).toContain('

package.json

') 406 | expect(text).toContain('

.github/workflows/test.yml

') 407 | }) 408 | 409 | test('addLicensesToSummary() - does not include specific license type sub-section if nothing is found', () => { 410 | const licenseIssues = { 411 | forbidden: [], 412 | unlicensed: [], 413 | unresolved: [createTestChange()] 414 | } 415 | 416 | summary.addLicensesToSummary(licenseIssues, defaultConfig) 417 | 418 | const text = core.summary.stringify() 419 | expect(text).not.toContain('Incompatible License') 420 | expect(text).not.toContain('Unknown License') 421 | expect(text).toContain('Invalid SPDX License') 422 | }) 423 | 424 | test('addLicensesToSummary() - includes list of configured allowed licenses', () => { 425 | const licenseIssues = { 426 | forbidden: [createTestChange()], 427 | unresolved: [], 428 | unlicensed: [] 429 | } 430 | 431 | const config: ConfigurationOptions = { 432 | ...defaultConfig, 433 | allow_licenses: ['MIT', 'Apache-2.0'] 434 | } 435 | 436 | summary.addLicensesToSummary(licenseIssues, config) 437 | 438 | const text = core.summary.stringify() 439 | expect(text).toContain('Allowed Licenses: MIT, Apache-2.0') 440 | }) 441 | 442 | test('addLicensesToSummary() - includes configured denied license', () => { 443 | const licenseIssues = { 444 | forbidden: [createTestChange()], 445 | unresolved: [], 446 | unlicensed: [] 447 | } 448 | 449 | const config: ConfigurationOptions = { 450 | ...defaultConfig, 451 | deny_licenses: ['MIT'] 452 | } 453 | 454 | summary.addLicensesToSummary(licenseIssues, config) 455 | 456 | const text = core.summary.stringify() 457 | expect(text).toContain('Denied Licenses: MIT') 458 | }) 459 | -------------------------------------------------------------------------------- /__tests__/test-helpers.ts: -------------------------------------------------------------------------------- 1 | // GitHub Action inputs come in the form of environment variables 2 | // with an INPUT prefix (e.g. INPUT_FAIL-ON-SEVERITY) 3 | export function setInput(input: string, value: string): void { 4 | process.env[`INPUT_${input.toUpperCase()}`] = value 5 | } 6 | 7 | // We want a clean ENV before each test. We use `delete` 8 | // since we want `undefined` values and not empty strings. 9 | export function clearInputs(): void { 10 | const allowedOptions = [ 11 | 'FAIL-ON-SEVERITY', 12 | 'FAIL-ON-SCOPES', 13 | 'ALLOW-LICENSES', 14 | 'ALLOW-DEPENDENCIES-LICENSES', 15 | 'DENY-LICENSES', 16 | 'ALLOW-GHSAS', 17 | 'LICENSE-CHECK', 18 | 'VULNERABILITY-CHECK', 19 | 'CONFIG-FILE', 20 | 'BASE-REF', 21 | 'HEAD-REF', 22 | 'COMMENT-SUMMARY-IN-PR', 23 | 'WARN-ONLY', 24 | 'DENY-GROUPS', 25 | 'DENY-PACKAGES' 26 | ] 27 | 28 | // eslint-disable-next-line github/array-foreach 29 | allowedOptions.forEach(option => { 30 | delete process.env[`INPUT_${option.toUpperCase()}`] 31 | }) 32 | } 33 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | # IMPORTANT 2 | # 3 | # Avoid setting default values for configuration options in 4 | # this file, they will overwrite external configurations. 5 | # 6 | # If you are trying to find out the default value for a config 7 | # option please take a look at the README or src/schemas.ts. 8 | # 9 | # If you are adding an option, make sure the Zod definition 10 | # contains a default value. 11 | name: 'Dependency Review' 12 | description: 'Prevent the introduction of dependencies with known vulnerabilities' 13 | author: 'GitHub' 14 | inputs: 15 | repo-token: 16 | description: Token for the repository. Can be passed in using `{{ secrets.GITHUB_TOKEN }}`. 17 | required: false 18 | default: ${{ github.token }} 19 | fail-on-severity: 20 | description: Don't block PRs below this severity. Possible values are `low`, `moderate`, `high`, `critical`. 21 | required: false 22 | fail-on-scopes: 23 | description: Dependency scopes to block PRs on. Comma-separated list. Possible values are 'unknown', 'runtime', and 'development' (e.g. "runtime, development") 24 | required: false 25 | base-ref: 26 | 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. 27 | required: false 28 | head-ref: 29 | 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. 30 | required: false 31 | config-file: 32 | description: A path to the configuration file for the action. 33 | required: false 34 | allow-licenses: 35 | description: Comma-separated list of allowed licenses (e.g. "MIT, GPL 3.0, BSD 2 Clause") 36 | required: false 37 | deny-licenses: 38 | description: Comma-separated list of forbidden licenses (e.g. "MIT, GPL 3.0, BSD 2 Clause") 39 | required: false 40 | allow-dependencies-licenses: 41 | description: Comma-separated list of dependencies in purl format (e.g. "pkg:npm/express, pkg:pypi/pycrypto"). These dependencies will be permitted to use any license, no matter what license policy is enforced otherwise. 42 | required: false 43 | allow-ghsas: 44 | description: Comma-separated list of allowed GitHub Advisory IDs (e.g. "GHSA-abcd-1234-5679, GHSA-efgh-1234-5679") 45 | required: false 46 | external-repo-token: 47 | description: A token for fetching external configuration file if it lives in another repository. It is required if the repository is private 48 | required: false 49 | license-check: 50 | description: A boolean to determine if license checks should be performed 51 | required: false 52 | vulnerability-check: 53 | description: A boolean to determine if vulnerability checks should be performed 54 | required: false 55 | comment-summary-in-pr: 56 | description: Determines if the summary is posted as a comment in the PR itself. Setting this to `always` or `on-failure` requires you to give the workflow the write permissions for pull-requests 57 | required: false 58 | deny-packages: 59 | description: A comma-separated list of package URLs to deny (e.g. "pkg:npm/express, pkg:pypi/pycrypto"). If version specified, only deny matching packages and version; else, deny all regardless of version. 60 | required: false 61 | deny-groups: 62 | description: A comma-separated list of package URLs for group(s)/namespace(s) to deny (e.g. "pkg:npm/express/, pkg:pypi/pycrypto/"). Please note that the group name must be followed by a `/`. 63 | required: false 64 | retry-on-snapshot-warnings: 65 | description: Whether to retry on snapshot warnings 66 | required: false 67 | retry-on-snapshot-warnings-timeout: 68 | description: Number of seconds to wait before stopping snapshot retries. 69 | required: false 70 | warn-only: 71 | description: When set to `true` this action will always complete with success, overriding the `fail-on-severity` parameter. 72 | required: false 73 | show-openssf-scorecard: 74 | description: Show a summary of the OpenSSF Scorecard scores. 75 | required: false 76 | warn-on-openssf-scorecard-level: 77 | description: Numeric threshold for the OpenSSF Scorecard score. If the score is below this threshold, the action will warn you. 78 | required: false 79 | outputs: 80 | comment-content: 81 | description: Prepared dependency report comment 82 | dependency-changes: 83 | description: All dependency changes (JSON) 84 | vulnerable-changes: 85 | description: Vulnerable dependency changes (JSON) 86 | invalid-license-changes: 87 | description: Invalid license dependency changes (JSON) 88 | denied-changes: 89 | description: Denied dependency changes (JSON) 90 | 91 | runs: 92 | using: 'node20' 93 | main: 'dist/index.js' 94 | -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | # Examples of how to use the Dependency Review Action 2 | 3 | ## Basic Usage 4 | 5 | A very basic example of how to use the action. This will run the action with the default configuration. 6 | 7 | The full list of configuration options can be found [here](../README.md#configuration-options). 8 | 9 | ```yaml 10 | name: 'Dependency Review' 11 | on: [pull_request] 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | dependency-review: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: 'Checkout Repository' 21 | uses: actions/checkout@v4 22 | - name: 'Dependency Review' 23 | uses: actions/dependency-review-action@v4 24 | ``` 25 | 26 | ## Using an inline configuration 27 | 28 | The following example will fail the action if any vulnerabilities are found with a severity of medium or higher; and if any packages are found with an incompatible license - in this case, the LGPL-2.0 and BSD-2-Clause licenses. 29 | 30 | ```yaml 31 | name: 'Dependency Review' 32 | on: [pull_request] 33 | 34 | permissions: 35 | contents: read 36 | 37 | jobs: 38 | dependency-review: 39 | runs-on: ubuntu-latest 40 | steps: 41 | - name: 'Checkout Repository' 42 | uses: actions/checkout@v4 43 | - name: 'Dependency Review' 44 | uses: actions/dependency-review-action@v4 45 | with: 46 | fail-on-severity: critical 47 | deny-licenses: LGPL-2.0, BSD-2-Clause 48 | ``` 49 | 50 | ## Using a configuration file 51 | 52 | The following example will use a configuration file to configure the action. This is useful if you want to keep your configuration in a single place and makes it easier to manage as the configuration grows. 53 | 54 | The configuration file can be located in the same repository or in a separate repository. Having it in a separate repository might be useful if you plan to use the same configuration across multiple repositories and control it centrally. 55 | 56 | In this example, the configuration file is located in the same repository under `.github/dependency-review-config.yml`. The following configuration will fail the action if any vulnerabilities are found with a severity of critical; and if any packages are found with an incompatible license - in this case, the LGPL-2.0 and BSD-2-Clause licenses. 57 | 58 | ```yaml 59 | fail_on_severity: 'critical' 60 | allow_licenses: 61 | - 'LGPL-2.0' 62 | - 'BSD-2-Clause' 63 | ``` 64 | 65 | The Dependency Review Action workflow file will then look like this: 66 | 67 | ```yaml 68 | name: 'Dependency Review' 69 | on: [pull_request] 70 | 71 | permissions: 72 | contents: read 73 | 74 | jobs: 75 | dependency-review: 76 | runs-on: ubuntu-latest 77 | steps: 78 | - name: 'Checkout Repository' 79 | uses: actions/checkout@v4 80 | - name: 'Dependency Review' 81 | uses: actions/dependency-review-action@v4 82 | with: 83 | config-file: './.github/dependency-review-config.yml' 84 | ``` 85 | 86 | ## Using a configuration file from an external repository 87 | 88 | The following example will use a configuration file from an external public GitHub repository to configure the action. 89 | 90 | Let's say that the configuration file is located in `github/octorepo/dependency-review-config.yml@main` 91 | 92 | The Dependency Review Action workflow file will then look like this: 93 | 94 | ```yaml 95 | name: 'Dependency Review' 96 | on: [pull_request] 97 | 98 | permissions: 99 | contents: read 100 | 101 | jobs: 102 | dependency-review: 103 | runs-on: ubuntu-latest 104 | steps: 105 | - name: 'Checkout Repository' 106 | uses: actions/checkout@v4 107 | - name: 'Dependency Review' 108 | uses: actions/dependency-review-action@v4 109 | with: 110 | config-file: 'github/octorepo/dependency-review-config.yml@main' 111 | ``` 112 | 113 | ## Using a configuration file from an external repository with a personal access token 114 | 115 | The following example will use a configuration file from an external private GtiHub repository to configure the action. 116 | 117 | Let's say that the configuration file is located in `github/octorepo-private/dependency-review-config.yml@main` 118 | 119 | The Dependency Review Action workflow file will then look like this: 120 | 121 | ```yaml 122 | name: 'Dependency Review' 123 | on: [pull_request] 124 | 125 | permissions: 126 | contents: read 127 | 128 | jobs: 129 | dependency-review: 130 | runs-on: ubuntu-latest 131 | steps: 132 | - name: 'Checkout Repository' 133 | uses: actions/checkout@v4 134 | - name: 'Dependency Review' 135 | uses: actions/dependency-review-action@v4 136 | with: 137 | config-file: 'github/octorepo-private/dependency-review-config.yml@main' 138 | external-repo-token: ${{ secrets.GITHUB_TOKEN }} # or a personal access token 139 | ``` 140 | 141 | ## Getting the results of the action in the PR as a comment 142 | 143 | Using the `comment-summary-in-pr` you can get the results of the action in the PR as a comment. In order for this to work, the action needs to be able to create a comment in the PR. This requires additional `pull-requests: write` permission. 144 | 145 | ```yaml 146 | name: 'Dependency Review' 147 | on: [pull_request] 148 | 149 | permissions: 150 | contents: read 151 | pull-requests: write 152 | 153 | jobs: 154 | dependency-review: 155 | runs-on: ubuntu-latest 156 | steps: 157 | - name: 'Checkout Repository' 158 | uses: actions/checkout@v4 159 | - name: 'Dependency Review' 160 | uses: actions/dependency-review-action@v4 161 | with: 162 | fail-on-severity: critical 163 | deny-licenses: LGPL-2.0, BSD-2-Clause 164 | comment-summary-in-pr: always 165 | ``` 166 | 167 | ## Getting the results of the action in a later step 168 | 169 | - `comment-content` contains the output of the results comment for the entire run. 170 | `dependency-changes`, `vulnerable-changes`, `invalid-license-changes` and `denied-changes` are all JSON objects that allow you to access individual sets of changes. 171 | 172 | ```yaml 173 | name: 'Dependency Review' 174 | on: [pull_request] 175 | 176 | permissions: 177 | contents: read 178 | pull-requests: write 179 | 180 | jobs: 181 | dependency-review: 182 | runs-on: ubuntu-latest 183 | steps: 184 | - name: 'Checkout Repository' 185 | uses: actions/checkout@v4 186 | - name: 'Dependency Review' 187 | id: review 188 | uses: actions/dependency-review-action@v4 189 | with: 190 | fail-on-severity: critical 191 | deny-licenses: LGPL-2.0, BSD-2-Clause 192 | - name: 'Report' 193 | # make sure this step runs even if the previous failed 194 | if: ${{ failure() && steps.review.conclusion == 'failure' }} 195 | shell: bash 196 | env: # store comment HTML data in an environment variable 197 | COMMENT: ${{ steps.review.outputs.comment-content }} 198 | run: | # do something with the comment: 199 | echo "$COMMENT" 200 | - name: 'List vulnerable dependencies' 201 | # make sure this step runs even if the previous failed 202 | if: ${{ failure() && steps.review.conclusion == 'failure' }} 203 | shell: bash 204 | env: # store JSON data in an environment variable 205 | VULNERABLE_CHANGES: ${{ steps.review.outputs.vulnerable-changes }} 206 | run: | # do something with the JSON: 207 | echo "$VULNERABLE_CHANGES" | jq '.[].package_url' 208 | ``` 209 | 210 | ## Exclude dependencies from the license check 211 | 212 | Using the `allow-dependencies-licenses` you can exclude dependencies from the license check. The values should be provided in [purl](https://github.com/package-url/purl-spec) format. 213 | 214 | In this example, we are excluding `lodash` from `npm` and `requests` from `pip` dependencies from the license check 215 | 216 | ```yaml 217 | name: 'Dependency Review' 218 | on: [pull_request] 219 | 220 | permissions: 221 | contents: read 222 | pull-requests: write 223 | 224 | jobs: 225 | dependency-review: 226 | runs-on: ubuntu-latest 227 | steps: 228 | - name: 'Checkout Repository' 229 | uses: actions/checkout@v4 230 | - name: 'Dependency Review' 231 | uses: actions/dependency-review-action@v4 232 | with: 233 | fail-on-severity: critical 234 | deny-licenses: LGPL-2.0, BSD-2-Clause 235 | comment-summary-in-pr: always 236 | allow-dependencies-licenses: 'pkg:npm/loadash, pkg:pypi/requests' 237 | ``` 238 | 239 | If we were to use configuration file, the configuration would look like this: 240 | 241 | ```yaml 242 | fail-on-severity: 'critical' 243 | allow-licenses: 244 | - 'LGPL-2.0' 245 | - 'BSD-2-Clause' 246 | allow-dependencies-licenses: 247 | - 'pkg:npm/loadash' 248 | - 'pkg:pypi/requests' 249 | ``` 250 | 251 | ## Only check for vulnerabilities 252 | 253 | To only do the vulnerability check you can use the `license-check` to disable the license compatibility check (which is done by default). 254 | 255 | ```yaml 256 | name: 'Dependency Review' 257 | on: [pull_request] 258 | 259 | permissions: 260 | contents: read 261 | pull-requests: write 262 | 263 | jobs: 264 | dependency-review: 265 | runs-on: ubuntu-latest 266 | steps: 267 | - name: 'Checkout Repository' 268 | uses: actions/checkout@v4 269 | - name: 'Dependency Review' 270 | uses: actions/dependency-review-action@v4 271 | with: 272 | fail-on-severity: critical 273 | comment-summary-in-pr: always 274 | license-check: false 275 | ``` 276 | 277 | ## Exclude dependencies from their name or groups 278 | 279 | With the `deny-packages` option, you can exclude dependencies based on their PURL (Package URL). If a specific version is provided, the action will deny packages matching that version. When no version is specified, the action treats it as a wildcard, denying all matching packages regardless of version. Multiple values can be added, separated by commas. 280 | 281 | Using the `deny-groups` option you can exclude dependencies by their group name/namespace. You can add multiple values separated by a comma. 282 | 283 | In this example, we are excluding all versions of `pkg:maven/org.apache.logging.log4j:log4j-api` and only `2.23.0` of log4j-core `pkg:maven/org.apache.logging.log4j/log4j-core@2.23.0` from `maven` and all packages in the group `pkg:maven/com.bazaarvoice.maven/` 284 | 285 | ```yaml 286 | name: 'Dependency Review' 287 | on: [pull_request] 288 | 289 | permissions: 290 | contents: read 291 | pull-requests: write 292 | 293 | jobs: 294 | dependency-review: 295 | runs-on: ubuntu-latest 296 | steps: 297 | - name: 'Checkout Repository' 298 | uses: actions/checkout@v4 299 | - name: 'Dependency Review' 300 | uses: actions/dependency-review-action@v4 301 | with: 302 | deny-packages: 'pkg:maven/org.apache.logging.log4j/log4j-api,pkg:maven/org.apache.logging.log4j/log4j-core@2.23.0' 303 | deny-groups: 'pkg:maven/com.bazaarvoice.jolt/' 304 | ``` 305 | 306 | ## Waiting for dependency submission jobs to complete 307 | 308 | When possible, this action will [include dependencies submitted through the dependency submission API][DSAPI]. In this case, 309 | it's important for the action not to complete until all of the relevant dependencies have been submitted for both the base 310 | and head commits. 311 | 312 | When this action runs before one or more of the dependency submission actions, there will be an unequal number of dependency 313 | snapshots between the base and head commits. For example, there may be one snapshot available for the tip of `main` and none 314 | for the PR branch. In that case, the API response will contain a "snapshot warning" explaining the discrepancy. 315 | 316 | In this example, when the action encounters one of these warnings it will retry every 10 seconds after that for 60 seconds 317 | or until there is no warning in the response. 318 | 319 | ```yaml 320 | name: 'Dependency Review' 321 | on: [pull_request] 322 | 323 | permissions: 324 | contents: read 325 | pull-requests: write 326 | 327 | jobs: 328 | dependency-review: 329 | runs-on: ubuntu-latest 330 | steps: 331 | - name: 'Checkout Repository' 332 | uses: actions/checkout@v4 333 | - name: 'Dependency Review' 334 | uses: actions/dependency-review-action@v4 335 | with: 336 | retry-on-snapshot-warnings: true 337 | retry-on-snapshot-warnings-timeout: 60 338 | ``` 339 | 340 | [DSAPI]: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#best-practices-for-using-the-dependency-review-api-and-the-dependency-submission-api-together 341 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'json', 'ts'], 4 | testMatch: ['**/*.test.ts'], 5 | transform: { 6 | '^.+\\.ts$': 'ts-jest' 7 | }, 8 | verbose: true 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dependency-review-action", 3 | "version": "4.7.1", 4 | "private": true, 5 | "description": "A GitHub Action for Dependency Review", 6 | "main": "lib/main.js", 7 | "scripts": { 8 | "build": "tsc -p tsconfig.build.json", 9 | "format": "prettier --write '**/*.ts'", 10 | "format-check": "prettier --check '**/*.ts'", 11 | "lint": "eslint src/**/*.ts", 12 | "package": "ncc build --source-map --license licenses.txt", 13 | "test": "jest", 14 | "all": "npm run build && npm run format && npm run lint && npm run package && npm test" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/github/dependency-review-action.git" 19 | }, 20 | "keywords": [ 21 | "actions", 22 | "node", 23 | "setup" 24 | ], 25 | "author": "GitHub", 26 | "license": "MIT", 27 | "dependencies": { 28 | "@actions/core": "^1.10.1", 29 | "@actions/github": "^6.0.0", 30 | "@octokit/plugin-retry": "^6.1.0", 31 | "@octokit/request-error": "^5.1.1", 32 | "@octokit/types": "12.5.0", 33 | "@onebeyond/spdx-license-satisfies": "^1.0.1", 34 | "ansi-styles": "^6.2.1", 35 | "got": "^14.4.5", 36 | "jest": "^29.7.0", 37 | "octokit": "^3.1.2", 38 | "spdx-expression-parse": "^3.0.1", 39 | "spdx-satisfies": "^6.0.0", 40 | "ts-jest": "^29.2.5", 41 | "yaml": "^2.3.4", 42 | "zod": "^3.24.1" 43 | }, 44 | "devDependencies": { 45 | "@types/jest": "^29.5.12", 46 | "@types/node": "^20", 47 | "@types/spdx-expression-parse": "^3.0.4", 48 | "@typescript-eslint/eslint-plugin": "^6.21.0", 49 | "@typescript-eslint/parser": "^6.21.0", 50 | "@vercel/ncc": "^0.38.3", 51 | "esbuild-register": "^3.6.0", 52 | "eslint": "^8.57.0", 53 | "eslint-plugin-github": "^4.10.2", 54 | "eslint-plugin-jest": "^28.8.3", 55 | "eslint-plugin-prettier": "^5.1.3", 56 | "js-yaml": "^4.1.0", 57 | "nodemon": "^3.1.9", 58 | "prettier": "3.2.5", 59 | "typescript": "^5.4.5" 60 | }, 61 | "overrides": { 62 | "cross-spawn": ">=7.0.5", 63 | "@octokit/request-error@5.0.1": "5.1.1" 64 | } 65 | } -------------------------------------------------------------------------------- /scripts/create_summary.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This scripts creates example markdown files for the summary in the ./tmp folder. 3 | * You can use it to preview changes to the summary. 4 | * 5 | * You can execute it like this: 6 | * npx ts-node scripts/create_summary.ts 7 | */ 8 | 9 | import {Change, Changes, ConfigurationOptions, Scorecard} from '../src/schemas' 10 | import {createTestChange} from '../__tests__/fixtures/create-test-change' 11 | import {InvalidLicenseChanges} from '../src/licenses' 12 | import * as fs from 'fs' 13 | import * as core from '@actions/core' 14 | import * as summary from '../src/summary' 15 | import * as path from 'path' 16 | 17 | const defaultConfig: ConfigurationOptions = { 18 | vulnerability_check: true, 19 | license_check: true, 20 | fail_on_severity: 'high', 21 | fail_on_scopes: ['runtime'], 22 | allow_ghsas: [], 23 | allow_licenses: ['MIT'], 24 | deny_licenses: [], 25 | deny_packages: [], 26 | deny_groups: [], 27 | allow_dependencies_licenses: [ 28 | 'pkg:npm/express@4.17.1', 29 | 'pkg:pypi/requests', 30 | 'pkg:pypi/certifi', 31 | 'pkg:pypi/pycrypto@2.6.1' 32 | ], 33 | comment_summary_in_pr: true, 34 | retry_on_snapshot_warnings: false, 35 | retry_on_snapshot_warnings_timeout: 120, 36 | warn_only: false, 37 | warn_on_openssf_scorecard_level: 3, 38 | show_openssf_scorecard: true 39 | } 40 | 41 | const scorecard: Scorecard = { 42 | dependencies: [ 43 | { 44 | change: { 45 | change_type: 'added', 46 | manifest: '', 47 | ecosystem: 'unknown', 48 | name: 'castore', 49 | version: '0.1.17', 50 | package_url: 'pkg:hex/castore@0.1.17', 51 | license: null, 52 | source_repository_url: null, 53 | scope: 'runtime', 54 | vulnerabilities: [] 55 | }, 56 | scorecard: null 57 | } 58 | ] 59 | } 60 | 61 | const tmpDir = path.resolve(__dirname, '../tmp') 62 | 63 | const createExampleSummaries = async (): Promise => { 64 | await fs.promises.mkdir(tmpDir, {recursive: true}) 65 | 66 | await createNonIssueSummary() 67 | await createFullSummary() 68 | } 69 | 70 | const createNonIssueSummary = async (): Promise => { 71 | await createSummary( 72 | [], 73 | {forbidden: [], unresolved: [], unlicensed: []}, 74 | [], 75 | defaultConfig, 76 | 'non-issue-summary.md' 77 | ) 78 | } 79 | 80 | const createFullSummary = async (): Promise => { 81 | const changes = [createTestChange()] 82 | const licenses: InvalidLicenseChanges = { 83 | forbidden: [ 84 | createTestChange({ 85 | name: 'underscore', 86 | version: '1.12.0', 87 | license: 'Apache 2.0' 88 | }) 89 | ], 90 | unresolved: [ 91 | createTestChange({ 92 | name: 'octoinvader', 93 | license: 'Non SPDX License' 94 | }), 95 | createTestChange({ 96 | name: 'owner/action-1', 97 | license: 'XYZ-License', 98 | version: 'v1.2.2', 99 | manifest: '.github/workflows/action.yml' 100 | }) 101 | ], 102 | unlicensed: [ 103 | createTestChange({ 104 | name: 'my-other-dependency', 105 | license: null 106 | }), 107 | createTestChange({ 108 | name: 'owner/action-2', 109 | version: 'main', 110 | license: null, 111 | manifest: '.github/workflows/action.yml' 112 | }) 113 | ] 114 | } 115 | 116 | await createSummary(changes, licenses, [], defaultConfig, 'full-summary.md') 117 | } 118 | 119 | async function createSummary( 120 | vulnerabilities: Changes, 121 | licenseIssues: InvalidLicenseChanges, 122 | denied: Change[], 123 | config: ConfigurationOptions, 124 | fileName: string 125 | ): Promise { 126 | summary.addSummaryToSummary( 127 | vulnerabilities, 128 | licenseIssues, 129 | denied, 130 | scorecard, 131 | config 132 | ) 133 | summary.addChangeVulnerabilitiesToSummary( 134 | vulnerabilities, 135 | config.fail_on_severity 136 | ) 137 | summary.addLicensesToSummary(licenseIssues, defaultConfig) 138 | 139 | const allChanges = [ 140 | ...vulnerabilities, 141 | ...licenseIssues.forbidden, 142 | ...licenseIssues.unresolved, 143 | ...licenseIssues.unlicensed 144 | ] 145 | 146 | summary.addScannedFiles(allChanges) 147 | 148 | const text = core.summary.stringify() 149 | await fs.promises.writeFile(path.resolve(tmpDir, fileName), text, { 150 | flag: 'w' 151 | }) 152 | core.summary.emptyBuffer() 153 | } 154 | 155 | createExampleSummaries() 156 | -------------------------------------------------------------------------------- /scripts/scan_pr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'json' 3 | require 'tempfile' 4 | require 'open3' 5 | require 'bundler/inline' 6 | require 'optparse' 7 | 8 | gemfile do 9 | source 'https://rubygems.org' 10 | gem 'octokit' 11 | end 12 | 13 | config_file = nil 14 | github_token = ENV["GITHUB_TOKEN"] 15 | 16 | if !github_token || github_token.empty? 17 | puts "Please set the GITHUB_TOKEN environment variable" 18 | exit -1 19 | end 20 | 21 | op = OptionParser.new do |opts| 22 | usage = < 27 | 28 | \e[1mExample:\e[22m 29 | scripts/scan_pr https://github.com/actions/dependency-review-action/pull/294 30 | 31 | EOF 32 | 33 | opts.banner = usage 34 | 35 | opts.on('-c', '--config-file ', 'Use an external configuration file') do |cf| 36 | config_file = cf 37 | end 38 | 39 | opts.on("-h", "--help", "Prints this help") do 40 | puts opts 41 | exit 42 | end 43 | end 44 | 45 | op.parse! 46 | 47 | # make sure we have a NWO somewhere in the parameters 48 | arg = /(?[\w\-]+\/[\w\-]+)\/pull\/(?\d+)/.match(ARGV.join(" ")) 49 | 50 | if arg.nil? 51 | puts op 52 | exit -1 53 | end 54 | 55 | repo_nwo = arg[:repo_nwo] 56 | pr_number = arg[:pr_number] 57 | 58 | octo = Octokit::Client.new(access_token: github_token) 59 | pr = octo.pull_request(repo_nwo, pr_number) 60 | 61 | event_file = Tempfile.new 62 | event_file.write("{ \"pull_request\": #{pr.to_h.to_json}}") 63 | event_file.close 64 | 65 | action_inputs = { 66 | "repo-token": github_token, 67 | "config-file": config_file 68 | } 69 | 70 | dev_cmd_env = { 71 | "GITHUB_REPOSITORY" => repo_nwo, 72 | "GITHUB_EVENT_NAME" => "pull_request", 73 | "GITHUB_EVENT_PATH" => event_file.path, 74 | "GITHUB_STEP_SUMMARY" => "/dev/null" 75 | } 76 | 77 | # bash does not like variable names with dashes like the ones Actions 78 | # uses (e.g. INPUT_REPO-TOKEN). Passing them through `env` instead of 79 | # manually setting them does the job. 80 | action_inputs_env_str = action_inputs.map { |name, value| "\"INPUT_#{name.upcase}=#{value}\"" }.join(" ") 81 | dev_cmd = "./node_modules/.bin/nodemon --exec \"env #{action_inputs_env_str} node -r esbuild-register\" src/main.ts" 82 | 83 | Open3.popen2e(dev_cmd_env, dev_cmd) do |stdin, out| 84 | while line = out.gets 85 | puts line.gsub(github_token, "") 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /src/comment-pr.ts: -------------------------------------------------------------------------------- 1 | import * as github from '@actions/github' 2 | import * as core from '@actions/core' 3 | import * as githubUtils from '@actions/github/lib/utils' 4 | import * as retry from '@octokit/plugin-retry' 5 | import {RequestError} from '@octokit/request-error' 6 | import {ConfigurationOptions} from './schemas' 7 | 8 | export const MAX_COMMENT_LENGTH = 65536 9 | 10 | const retryingOctokit = githubUtils.GitHub.plugin(retry.retry) 11 | const octo = new retryingOctokit( 12 | githubUtils.getOctokitOptions(core.getInput('repo-token', {required: true})) 13 | ) 14 | 15 | // Comment Marker to identify an existing comment to update, so we don't spam the PR with comments 16 | const COMMENT_MARKER = '' 17 | 18 | export async function commentPr( 19 | commentContent: string, 20 | config: ConfigurationOptions, 21 | issueFound: boolean 22 | ): Promise { 23 | if ( 24 | !( 25 | config.comment_summary_in_pr === 'always' || 26 | (config.comment_summary_in_pr === 'on-failure' && issueFound) 27 | ) 28 | ) { 29 | return 30 | } 31 | 32 | if (!github.context.payload.pull_request) { 33 | core.warning( 34 | 'Not in the context of a pull request. Skipping comment creation.' 35 | ) 36 | return 37 | } 38 | 39 | const commentBody = `${commentContent}\n\n${COMMENT_MARKER}` 40 | 41 | try { 42 | const existingCommentId = await findCommentByMarker(COMMENT_MARKER) 43 | 44 | if (existingCommentId) { 45 | await octo.rest.issues.updateComment({ 46 | owner: github.context.repo.owner, 47 | repo: github.context.repo.repo, 48 | comment_id: existingCommentId, 49 | body: commentBody 50 | }) 51 | } else { 52 | await octo.rest.issues.createComment({ 53 | owner: github.context.repo.owner, 54 | repo: github.context.repo.repo, 55 | issue_number: github.context.payload.pull_request.number, 56 | body: commentBody 57 | }) 58 | } 59 | } catch (error) { 60 | if (error instanceof RequestError && error.status === 403) { 61 | core.warning( 62 | `Unable to write summary to pull-request. Make sure you are giving this workflow the permission 'pull-requests: write'.` 63 | ) 64 | } else { 65 | if (error instanceof Error) { 66 | core.warning( 67 | `Unable to comment summary to pull-request, received error: ${error.message}` 68 | ) 69 | } else { 70 | core.warning( 71 | 'Unable to comment summary to pull-request: Unexpected fatal error' 72 | ) 73 | } 74 | } 75 | } 76 | } 77 | 78 | async function findCommentByMarker( 79 | commentBodyIncludes: string 80 | ): Promise { 81 | const commentsIterator = octo.paginate.iterator( 82 | octo.rest.issues.listComments, 83 | { 84 | owner: github.context.repo.owner, 85 | repo: github.context.repo.repo, 86 | // We are already checking if we are in the context of a pull request in the caller 87 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 88 | issue_number: github.context.payload.pull_request!.number 89 | } 90 | ) 91 | 92 | for await (const {data: comments} of commentsIterator) { 93 | const existingComment = comments.find(comment => 94 | comment.body?.includes(commentBodyIncludes) 95 | ) 96 | if (existingComment) return existingComment.id 97 | } 98 | 99 | return undefined 100 | } 101 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import path from 'path' 3 | import YAML from 'yaml' 4 | import * as core from '@actions/core' 5 | import * as z from 'zod' 6 | import {ConfigurationOptions, ConfigurationOptionsSchema} from './schemas' 7 | import {octokitClient} from './utils' 8 | import {isValid} from './spdx' 9 | 10 | type ConfigurationOptionsPartial = Partial 11 | 12 | export async function readConfig(): Promise { 13 | const inlineConfig = readInlineConfig() 14 | 15 | const configFile = getOptionalInput('config-file') 16 | if (configFile !== undefined) { 17 | const externalConfig = await readConfigFile(configFile) 18 | 19 | return ConfigurationOptionsSchema.parse({ 20 | ...externalConfig, 21 | ...inlineConfig 22 | }) 23 | } 24 | 25 | return ConfigurationOptionsSchema.parse(inlineConfig) 26 | } 27 | 28 | function readInlineConfig(): ConfigurationOptionsPartial { 29 | const fail_on_severity = getOptionalInput('fail-on-severity') 30 | const fail_on_scopes = parseList(getOptionalInput('fail-on-scopes')) 31 | const allow_licenses = parseList(getOptionalInput('allow-licenses')) 32 | const deny_licenses = parseList(getOptionalInput('deny-licenses')) 33 | const allow_dependencies_licenses = parseList( 34 | getOptionalInput('allow-dependencies-licenses') 35 | ) 36 | const deny_packages = parseList(getOptionalInput('deny-packages')) 37 | const deny_groups = parseList(getOptionalInput('deny-groups')) 38 | const allow_ghsas = parseList(getOptionalInput('allow-ghsas')) 39 | const license_check = getOptionalBoolean('license-check') 40 | const vulnerability_check = getOptionalBoolean('vulnerability-check') 41 | const base_ref = getOptionalInput('base-ref') 42 | const head_ref = getOptionalInput('head-ref') 43 | const comment_summary_in_pr = getOptionalInput('comment-summary-in-pr') 44 | const retry_on_snapshot_warnings = getOptionalBoolean( 45 | 'retry-on-snapshot-warnings' 46 | ) 47 | const retry_on_snapshot_warnings_timeout = getOptionalNumber( 48 | 'retry-on-snapshot-warnings-timeout' 49 | ) 50 | const warn_only = getOptionalBoolean('warn-only') 51 | const show_openssf_scorecard = getOptionalBoolean('show-openssf-scorecard') 52 | const warn_on_openssf_scorecard_level = getOptionalNumber( 53 | 'warn-on-openssf-scorecard-level' 54 | ) 55 | 56 | validateLicenses('allow-licenses', allow_licenses) 57 | validateLicenses('deny-licenses', deny_licenses) 58 | 59 | const keys = { 60 | fail_on_severity, 61 | fail_on_scopes, 62 | allow_licenses, 63 | deny_licenses, 64 | deny_packages, 65 | deny_groups, 66 | allow_dependencies_licenses, 67 | allow_ghsas, 68 | license_check, 69 | vulnerability_check, 70 | base_ref, 71 | head_ref, 72 | comment_summary_in_pr, 73 | retry_on_snapshot_warnings, 74 | retry_on_snapshot_warnings_timeout, 75 | warn_only, 76 | show_openssf_scorecard, 77 | warn_on_openssf_scorecard_level 78 | } 79 | 80 | return Object.fromEntries( 81 | Object.entries(keys).filter(([_, value]) => value !== undefined) 82 | ) 83 | } 84 | 85 | function getOptionalNumber(name: string): number | undefined { 86 | const value = core.getInput(name) 87 | const parsed = z.string().regex(/^\d+$/).transform(Number).safeParse(value) 88 | return parsed.success ? parsed.data : undefined 89 | } 90 | 91 | function getOptionalBoolean(name: string): boolean | undefined { 92 | const value = core.getInput(name) 93 | return value.length > 0 ? core.getBooleanInput(name) : undefined 94 | } 95 | 96 | function getOptionalInput(name: string): string | undefined { 97 | const value = core.getInput(name) 98 | return value.length > 0 ? value : undefined 99 | } 100 | 101 | function parseList(list: string | undefined): string[] | undefined { 102 | if (list === undefined) { 103 | return list 104 | } else { 105 | return list.split(',').map(x => x.trim()) 106 | } 107 | } 108 | 109 | function validateLicenses( 110 | key: 'allow-licenses' | 'deny-licenses', 111 | licenses: string[] | undefined 112 | ): void { 113 | if (licenses === undefined) { 114 | return 115 | } 116 | 117 | const invalid_licenses = licenses.filter(license => !isValid(license)) 118 | 119 | if (invalid_licenses.length > 0) { 120 | throw new Error( 121 | `Invalid license(s) in ${key}: ${invalid_licenses.join(', ')}` 122 | ) 123 | } 124 | } 125 | 126 | async function readConfigFile( 127 | filePath: string 128 | ): Promise { 129 | // match a remote config (e.g. 'owner/repo/filepath@someref') 130 | const format = new RegExp( 131 | '(?[^/]+)/(?[^/]+)/(?[^@]+)@(?.*)' 132 | ) 133 | 134 | let data: string 135 | const pieces = format.exec(filePath) 136 | 137 | try { 138 | if (pieces?.groups && pieces.length === 5) { 139 | data = await getRemoteConfig({ 140 | owner: pieces.groups.owner, 141 | repo: pieces.groups.repo, 142 | path: pieces.groups.path, 143 | ref: pieces.groups.ref 144 | }) 145 | } else { 146 | data = fs.readFileSync(path.resolve(filePath), 'utf-8') 147 | } 148 | return parseConfigFile(data) 149 | } catch (error) { 150 | throw new Error( 151 | `Unable to fetch or parse config file: ${(error as Error).message}` 152 | ) 153 | } 154 | } 155 | 156 | function parseConfigFile(configData: string): ConfigurationOptionsPartial { 157 | try { 158 | const data = YAML.parse(configData) 159 | 160 | // These are the options that we support where the user can provide 161 | // either a YAML list or a comma-separated string. 162 | const listKeys = [ 163 | 'allow-licenses', 164 | 'deny-licenses', 165 | 'fail-on-scopes', 166 | 'allow-ghsas', 167 | 'allow-dependencies-licenses', 168 | 'deny-packages', 169 | 'deny-groups' 170 | ] 171 | 172 | for (const key of Object.keys(data)) { 173 | // strings can contain list values (e.g. 'MIT, Apache-2.0'). In this 174 | // case we need to parse that into a list (e.g. ['MIT', 'Apache-2.0']). 175 | if (listKeys.includes(key)) { 176 | const val = data[key] 177 | 178 | if (typeof val === 'string') { 179 | data[key] = val.split(',').map(x => x.trim()) 180 | } 181 | } 182 | 183 | // perform SPDX validation 184 | if (key === 'allow-licenses' || key === 'deny-licenses') { 185 | validateLicenses(key, data[key]) 186 | } 187 | 188 | // get rid of the ugly dashes from the actions conventions 189 | if (key.includes('-')) { 190 | data[key.replace(/-/g, '_')] = data[key] 191 | delete data[key] 192 | } 193 | } 194 | return data 195 | } catch (error) { 196 | throw error 197 | } 198 | } 199 | 200 | async function getRemoteConfig(configOpts: { 201 | [key: string]: string 202 | }): Promise { 203 | try { 204 | const {data} = await octokitClient( 205 | 'external-repo-token', 206 | false 207 | ).rest.repos.getContent({ 208 | mediaType: { 209 | format: 'raw' 210 | }, 211 | owner: configOpts.owner, 212 | repo: configOpts.repo, 213 | path: configOpts.path, 214 | ref: configOpts.ref 215 | }) 216 | 217 | // When using mediaType.format = 'raw', the response.data is a string 218 | // but this is not reflected in the return type of getContent, so we're 219 | // casting the return value to a string. 220 | return z.string().parse(data as unknown) 221 | } catch (error) { 222 | core.debug(error as string) 223 | throw new Error('Error fetching remote config file') 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /src/deny.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {Change} from './schemas' 3 | import {PackageURL, parsePURL} from './purl' 4 | 5 | export async function getDeniedChanges( 6 | changes: Change[], 7 | deniedPackages: PackageURL[] = [], 8 | deniedGroups: PackageURL[] = [] 9 | ): Promise { 10 | const changesDenied: Change[] = [] 11 | 12 | for (const change of changes) { 13 | if (change.change_type === 'removed') { 14 | continue 15 | } 16 | 17 | for (const denied of deniedPackages) { 18 | if ( 19 | (!denied.version || change.version === denied.version) && 20 | change.name === denied.name 21 | ) { 22 | changesDenied.push(change) 23 | } 24 | } 25 | 26 | for (const denied of deniedGroups) { 27 | const namespace = getNamespace(change) 28 | if (!denied.namespace) { 29 | core.error( 30 | `Denied group represented by '${denied.original}' does not have a namespace. The format should be 'pkg://'.` 31 | ) 32 | } 33 | if (namespace && namespace === denied.namespace) { 34 | changesDenied.push(change) 35 | } 36 | } 37 | } 38 | 39 | return changesDenied 40 | } 41 | 42 | export const getNamespace = (change: Change): string | null => { 43 | if (change.package_url) { 44 | return parsePURL(change.package_url).namespace 45 | } 46 | const matches = change.name.match(/([^:/]+)[:/]/) 47 | if (matches && matches.length > 1) { 48 | return matches[1] 49 | } 50 | return null 51 | } 52 | -------------------------------------------------------------------------------- /src/dependency-graph.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as githubUtils from '@actions/github/lib/utils' 3 | import * as retry from '@octokit/plugin-retry' 4 | import { 5 | ChangesSchema, 6 | ComparisonResponse, 7 | ComparisonResponseSchema 8 | } from './schemas' 9 | 10 | const retryingOctokit = githubUtils.GitHub.plugin(retry.retry) 11 | const SnapshotWarningsHeader = 'x-github-dependency-graph-snapshot-warnings' 12 | const octo = new retryingOctokit( 13 | githubUtils.getOctokitOptions(core.getInput('repo-token', {required: true})) 14 | ) 15 | 16 | export async function compare({ 17 | owner, 18 | repo, 19 | baseRef, 20 | headRef 21 | }: { 22 | owner: string 23 | repo: string 24 | baseRef: string 25 | headRef: string 26 | }): Promise { 27 | let snapshot_warnings = '' 28 | const changes = await octo.paginate( 29 | { 30 | method: 'GET', 31 | url: '/repos/{owner}/{repo}/dependency-graph/compare/{basehead}', 32 | owner, 33 | repo, 34 | basehead: `${baseRef}...${headRef}`, 35 | per_page: 5 36 | }, 37 | response => { 38 | if ( 39 | response.headers[SnapshotWarningsHeader] && 40 | typeof response.headers[SnapshotWarningsHeader] === 'string' 41 | ) { 42 | snapshot_warnings = Buffer.from( 43 | response.headers[SnapshotWarningsHeader], 44 | 'base64' 45 | ).toString('utf-8') 46 | } 47 | return ChangesSchema.parse(response.data) 48 | } 49 | ) 50 | return ComparisonResponseSchema.parse({ 51 | changes, 52 | snapshot_warnings 53 | }) 54 | } 55 | -------------------------------------------------------------------------------- /src/filter.ts: -------------------------------------------------------------------------------- 1 | import {Changes, Severity, SEVERITIES, Scope} from './schemas' 2 | 3 | /** 4 | * Filters changes by a severity level. Only vulnerable 5 | * dependencies will be returned. 6 | * 7 | * @param severity - The severity level to filter by. 8 | * @param changes - The array of changes to filter. 9 | * @returns The filtered array of changes that match the specified severity level and have vulnerabilities. 10 | */ 11 | export function filterChangesBySeverity( 12 | severity: Severity, 13 | changes: Changes 14 | ): Changes { 15 | const severityIdx = SEVERITIES.indexOf(severity) 16 | let filteredChanges = [] 17 | for (const change of changes) { 18 | if ( 19 | change === undefined || 20 | change.vulnerabilities === undefined || 21 | change.vulnerabilities.length === 0 22 | ) { 23 | continue 24 | } 25 | 26 | const fChange = { 27 | ...change, 28 | vulnerabilities: change.vulnerabilities.filter(vuln => { 29 | const vulnIdx = SEVERITIES.indexOf(vuln.severity) 30 | if (vulnIdx <= severityIdx) { 31 | return true 32 | } 33 | }) 34 | } 35 | filteredChanges.push(fChange) 36 | } 37 | 38 | // don't want to deal with changes with no vulnerabilities 39 | filteredChanges = filteredChanges.filter( 40 | change => change.vulnerabilities.length > 0 41 | ) 42 | 43 | // only report vulnerability additions 44 | return filteredChanges.filter( 45 | change => 46 | change.change_type === 'added' && 47 | change.vulnerabilities !== undefined && 48 | change.vulnerabilities.length > 0 49 | ) 50 | } 51 | 52 | export function filterChangesByScopes( 53 | scopes: Scope[] | undefined, 54 | changes: Changes 55 | ): Changes { 56 | if (scopes === undefined) { 57 | return [] 58 | } 59 | 60 | const filteredChanges = changes.filter(change => { 61 | // if there is no scope on the change (Enterprise Server API for now), we will assume it is a runtime scope 62 | const scope = change.scope || 'runtime' 63 | return scopes.includes(scope) 64 | }) 65 | 66 | return filteredChanges 67 | } 68 | 69 | /** 70 | * Filter out changes that are allowed by the allow_ghsas config 71 | * option. We want to remove these changes before we do any 72 | * processing. 73 | * @param ghsas - list of GHSA IDs to allow 74 | * @param changes - list of changes to filter 75 | * @returns a list of changes with the allowed GHSAs removed 76 | */ 77 | export function filterAllowedAdvisories( 78 | ghsas: string[] | undefined, 79 | changes: Changes 80 | ): Changes { 81 | if (ghsas === undefined) { 82 | return changes 83 | } 84 | 85 | const filteredChanges = changes.map(change => { 86 | const noAdvisories = 87 | change.vulnerabilities === undefined || 88 | change.vulnerabilities.length === 0 89 | 90 | if (noAdvisories) { 91 | return change 92 | } 93 | const newChange = {...change} 94 | newChange.vulnerabilities = change.vulnerabilities.filter( 95 | vuln => !ghsas.includes(vuln.advisory_ghsa_id) 96 | ) 97 | 98 | return newChange 99 | }) 100 | 101 | return filteredChanges 102 | } 103 | -------------------------------------------------------------------------------- /src/git-refs.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PullRequestSchema, 3 | ConfigurationOptions, 4 | MergeGroupSchema 5 | } from './schemas' 6 | 7 | export function getRefs( 8 | config: ConfigurationOptions, 9 | context: { 10 | payload: {pull_request?: unknown; merge_group?: unknown} 11 | eventName: string 12 | } 13 | ): {base: string; head: string} { 14 | let base_ref = config.base_ref 15 | let head_ref = config.head_ref 16 | 17 | // If possible, source default base & head refs from the GitHub event. 18 | // The base/head ref from the config take priority, if provided. 19 | if (!base_ref && !head_ref) { 20 | if ( 21 | context.eventName === 'pull_request' || 22 | context.eventName === 'pull_request_target' 23 | ) { 24 | const pull_request = PullRequestSchema.parse(context.payload.pull_request) 25 | base_ref = base_ref || pull_request.base.sha 26 | head_ref = head_ref || pull_request.head.sha 27 | } else if (context.eventName === 'merge_group') { 28 | const merge_group = MergeGroupSchema.parse(context.payload.merge_group) 29 | base_ref = base_ref || merge_group.base_sha 30 | head_ref = head_ref || merge_group.head_sha 31 | } 32 | } 33 | 34 | if (!base_ref && !head_ref) { 35 | throw new Error( 36 | 'Both a base ref and head ref must be provided, either via the `base_ref`/`head_ref` ' + 37 | 'config options, `base-ref`/`head-ref` workflow action options, or by running a ' + 38 | '`pull_request`/`pull_request_target`/`merge_group` workflow.' 39 | ) 40 | } else if (!base_ref) { 41 | throw new Error( 42 | 'A base ref must be provided, either via the `base_ref` config option, ' + 43 | '`base-ref` workflow action option, or by running a ' + 44 | '`pull_request`/`pull_request_target`/`merge_group` workflow.' 45 | ) 46 | } else if (!head_ref) { 47 | throw new Error( 48 | 'A head ref must be provided, either via the `head_ref` config option, ' + 49 | '`head-ref` workflow action option, or by running a ' + 50 | 'or by running a `pull_request`/`pull_request_target`/`merge_group` workflow.' 51 | ) 52 | } 53 | 54 | return { 55 | base: base_ref, 56 | head: head_ref 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/licenses.ts: -------------------------------------------------------------------------------- 1 | import {Change, Changes} from './schemas' 2 | import {octokitClient} from './utils' 3 | import {parsePURL, PackageURL} from './purl' 4 | import * as spdx from './spdx' 5 | 6 | /** 7 | * Loops through a list of changes, filtering and returning the 8 | * ones that don't conform to the licenses allow/deny lists. 9 | * It will also filter out the changes which are defined in the licenseExclusions list. 10 | * 11 | * Keep in mind that we don't let users specify both an allow and a deny 12 | * list in their config files, so this code works under the assumption that 13 | * one of the two list parameters will be empty. If both lists are provided, 14 | * we will ignore the deny list. 15 | * @param {Change[]} changes The list of changes to filter. 16 | * @param { { allow?: string[], deny?: string[], licenseExclusions?: string[]}} licenses An object with `allow`/`deny`/`licenseExclusions` keys, each containing a list of licenses. 17 | * @returns {Promise<{Object.>}} A promise to a Record Object. The keys are strings, unlicensed, unresolved and forbidden. The values are a list of changes 18 | */ 19 | export type InvalidLicenseChangeTypes = 20 | | 'unlicensed' 21 | | 'unresolved' 22 | | 'forbidden' 23 | export type InvalidLicenseChanges = Record 24 | export async function getInvalidLicenseChanges( 25 | changes: Change[], 26 | licenses: { 27 | allow?: string[] 28 | deny?: string[] 29 | licenseExclusions?: string[] 30 | } 31 | ): Promise { 32 | const deny = licenses.deny 33 | let allow = licenses.allow 34 | 35 | // Filter out elements of the allow list that include AND 36 | // or OR because the list should be simple license IDs and 37 | // not expressions. 38 | allow = allow?.filter(license => { 39 | return !license.includes(' AND ') && !license.includes(' OR ') 40 | }) 41 | 42 | const licenseExclusions = licenses.licenseExclusions?.map( 43 | (pkgUrl: string) => { 44 | return parsePURL(pkgUrl) 45 | } 46 | ) 47 | 48 | const groupedChanges = await groupChanges(changes, licenseExclusions) 49 | 50 | const licensedChanges: Changes = groupedChanges.licensed 51 | 52 | const invalidLicenseChanges: InvalidLicenseChanges = { 53 | unlicensed: groupedChanges.unlicensed, 54 | unresolved: [], 55 | forbidden: [] 56 | } 57 | 58 | const validityCache = new Map() 59 | 60 | for (const change of licensedChanges) { 61 | const license = change.license 62 | 63 | // should never happen since licensedChanges always have licenses but license is nullable in changes schema 64 | if (license === null) { 65 | continue 66 | } 67 | 68 | if (license === 'NOASSERTION') { 69 | invalidLicenseChanges.unlicensed.push(change) 70 | } else if (validityCache.get(license) === undefined) { 71 | try { 72 | if (allow !== undefined) { 73 | if (spdx.isValid(license)) { 74 | const found = spdx.satisfies(license, allow) 75 | validityCache.set(license, found) 76 | } else { 77 | invalidLicenseChanges.unresolved.push(change) 78 | } 79 | } else if (deny !== undefined) { 80 | if (spdx.isValid(license)) { 81 | const found = spdx.satisfiesAny(license, deny) 82 | validityCache.set(license, !found) 83 | } else { 84 | invalidLicenseChanges.unresolved.push(change) 85 | } 86 | } 87 | } catch (err) { 88 | invalidLicenseChanges.unresolved.push(change) 89 | } 90 | } 91 | 92 | if (validityCache.get(license) === false) { 93 | invalidLicenseChanges.forbidden.push(change) 94 | } 95 | } 96 | 97 | return invalidLicenseChanges 98 | } 99 | 100 | const fetchGHLicense = async ( 101 | owner: string, 102 | repo: string 103 | ): Promise => { 104 | try { 105 | const response = await octokitClient().rest.licenses.getForRepo({ 106 | owner, 107 | repo 108 | }) 109 | return response.data.license?.spdx_id ?? null 110 | } catch (_) { 111 | return null 112 | } 113 | } 114 | 115 | const parseGitHubURL = (url: string): {owner: string; repo: string} | null => { 116 | try { 117 | const parsed = new URL(url) 118 | if (parsed.host !== 'github.com') { 119 | return null 120 | } 121 | const components = parsed.pathname.split('/') 122 | if (components.length < 3) { 123 | return null 124 | } 125 | return {owner: components[1], repo: components[2]} 126 | } catch (_) { 127 | return null 128 | } 129 | } 130 | 131 | const setGHLicenses = async (changes: Change[]): Promise => { 132 | const updatedChanges = changes.map(async change => { 133 | if (change.license !== null || change.source_repository_url === null) { 134 | return change 135 | } 136 | 137 | const githubUrl = parseGitHubURL(change.source_repository_url) 138 | 139 | if (githubUrl === null) { 140 | return change 141 | } 142 | 143 | return { 144 | ...change, 145 | license: await fetchGHLicense(githubUrl.owner, githubUrl.repo) 146 | } 147 | }) 148 | 149 | return Promise.all(updatedChanges) 150 | } 151 | 152 | // Currently Dependency Graph licenses are truncated to 255 characters 153 | // This possibly makes them invalid spdx ids 154 | const truncatedDGLicense = (license: string): boolean => 155 | license.length === 255 && !spdx.isValid(license) 156 | 157 | async function groupChanges( 158 | changes: Changes, 159 | licenseExclusions: PackageURL[] | null = null 160 | ): Promise> { 161 | const result: Record = { 162 | licensed: [], 163 | unlicensed: [] 164 | } 165 | 166 | let candidateChanges = changes 167 | 168 | // If a package is excluded from license checking, we don't bother trying to 169 | // fetch the license for it and we leave it off of the `licensed` and 170 | // `unlicensed` lists. 171 | if (licenseExclusions !== null && licenseExclusions !== undefined) { 172 | candidateChanges = candidateChanges.filter(change => { 173 | if (change.package_url.length === 0) { 174 | return true 175 | } 176 | 177 | const changeAsPackageURL = parsePURL(encodeURI(change.package_url)) 178 | 179 | // We want to find if the licenseExclusion list contains the PackageURL of the Change 180 | // If it does, we want to filter it out and therefore return false 181 | // If it doesn't, we want to keep it and therefore return true 182 | if ( 183 | licenseExclusions.findIndex( 184 | exclusion => 185 | exclusion.type === changeAsPackageURL.type && 186 | exclusion.namespace === changeAsPackageURL.namespace && 187 | exclusion.name === changeAsPackageURL.name 188 | ) !== -1 189 | ) { 190 | return false 191 | } else { 192 | return true 193 | } 194 | }) 195 | } 196 | 197 | const ghChanges = [] 198 | 199 | for (const change of candidateChanges) { 200 | if (change.change_type === 'removed') { 201 | continue 202 | } 203 | 204 | if (change.license === null) { 205 | if (change.source_repository_url !== null) { 206 | ghChanges.push(change) 207 | } else { 208 | result.unlicensed.push(change) 209 | } 210 | } else { 211 | if ( 212 | truncatedDGLicense(change.license) && 213 | change.source_repository_url !== null 214 | ) { 215 | ghChanges.push(change) 216 | } else { 217 | result.licensed.push(change) 218 | } 219 | } 220 | } 221 | 222 | if (ghChanges.length > 0) { 223 | const ghLicenses = await setGHLicenses(ghChanges) 224 | for (const change of ghLicenses) { 225 | if (change.license === null) { 226 | result.unlicensed.push(change) 227 | } else { 228 | result.licensed.push(change) 229 | } 230 | } 231 | } 232 | 233 | return result 234 | } 235 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import * as dependencyGraph from './dependency-graph' 3 | import * as github from '@actions/github' 4 | import styles from 'ansi-styles' 5 | import {RequestError} from '@octokit/request-error' 6 | import { 7 | Change, 8 | Severity, 9 | Changes, 10 | ConfigurationOptions, 11 | Scorecard 12 | } from './schemas' 13 | import {readConfig} from '../src/config' 14 | import { 15 | filterChangesBySeverity, 16 | filterChangesByScopes, 17 | filterAllowedAdvisories 18 | } from '../src/filter' 19 | import {getInvalidLicenseChanges} from './licenses' 20 | import {getScorecardLevels} from './scorecard' 21 | import * as summary from './summary' 22 | import {getRefs} from './git-refs' 23 | 24 | import {groupDependenciesByManifest} from './utils' 25 | import {commentPr, MAX_COMMENT_LENGTH} from './comment-pr' 26 | import {getDeniedChanges} from './deny' 27 | 28 | async function delay(ms: number): Promise { 29 | return new Promise(resolve => setTimeout(resolve, ms)) 30 | } 31 | 32 | async function getComparison( 33 | baseRef: string, 34 | headRef: string, 35 | retryOpts?: { 36 | retryUntil: number 37 | retryDelay: number 38 | } 39 | ): ReturnType { 40 | const comparison = await dependencyGraph.compare({ 41 | owner: github.context.repo.owner, 42 | repo: github.context.repo.repo, 43 | baseRef, 44 | headRef 45 | }) 46 | 47 | if (comparison.snapshot_warnings.trim() !== '') { 48 | core.info(comparison.snapshot_warnings) 49 | if (retryOpts !== undefined) { 50 | if (retryOpts.retryUntil < Date.now()) { 51 | core.info(`Retry timeout exceeded. Proceeding...`) 52 | return comparison 53 | } else { 54 | core.info(`Retrying in ${retryOpts.retryDelay} seconds...`) 55 | await delay(retryOpts.retryDelay * 1000) 56 | return getComparison(baseRef, headRef, retryOpts) 57 | } 58 | } 59 | } 60 | 61 | return comparison 62 | } 63 | 64 | async function run(): Promise { 65 | try { 66 | const config = await readConfig() 67 | 68 | const refs = getRefs(config, github.context) 69 | 70 | const comparison = await getComparison( 71 | refs.base, 72 | refs.head, 73 | config.retry_on_snapshot_warnings 74 | ? { 75 | retryUntil: 76 | Date.now() + config.retry_on_snapshot_warnings_timeout * 1000, 77 | retryDelay: 10 78 | } 79 | : undefined 80 | ) 81 | 82 | const changes = comparison.changes 83 | const snapshot_warnings = comparison.snapshot_warnings 84 | 85 | if (!changes) { 86 | core.info('No Dependency Changes found. Skipping Dependency Review.') 87 | return 88 | } 89 | 90 | const scopedChanges = filterChangesByScopes(config.fail_on_scopes, changes) 91 | 92 | const filteredChanges = filterAllowedAdvisories( 93 | config.allow_ghsas, 94 | scopedChanges 95 | ) 96 | 97 | const failOnSeverityParams = config.fail_on_severity 98 | const warnOnly = config.warn_only 99 | let minSeverity: Severity = 'low' 100 | // If failOnSeverityParams is not set or warnOnly is true, the minSeverity is low, to allow all vulnerabilities to be reported as warnings 101 | if (failOnSeverityParams && !warnOnly) { 102 | minSeverity = failOnSeverityParams 103 | } 104 | 105 | const vulnerableChanges = filterChangesBySeverity( 106 | minSeverity, 107 | filteredChanges 108 | ) 109 | 110 | const invalidLicenseChanges = await getInvalidLicenseChanges( 111 | filteredChanges, 112 | { 113 | allow: config.allow_licenses, 114 | deny: config.deny_licenses, 115 | licenseExclusions: config.allow_dependencies_licenses 116 | } 117 | ) 118 | 119 | core.debug(`Filtered Changes: ${JSON.stringify(filteredChanges)}`) 120 | core.debug(`Config Deny Packages: ${JSON.stringify(config)}`) 121 | 122 | const deniedChanges = await getDeniedChanges( 123 | filteredChanges, 124 | config.deny_packages, 125 | config.deny_groups 126 | ) 127 | 128 | // generate informational scorecard entries for all added changes in the PR 129 | const scorecardChanges = getScorecardChanges(changes) 130 | const scorecard = await getScorecardLevels(scorecardChanges) 131 | 132 | const minSummary = summary.addSummaryToSummary( 133 | vulnerableChanges, 134 | invalidLicenseChanges, 135 | deniedChanges, 136 | scorecard, 137 | config 138 | ) 139 | 140 | if (snapshot_warnings) { 141 | summary.addSnapshotWarnings(config, snapshot_warnings) 142 | } 143 | 144 | let issueFound = false 145 | 146 | if (config.vulnerability_check) { 147 | core.setOutput('vulnerable-changes', JSON.stringify(vulnerableChanges)) 148 | summary.addChangeVulnerabilitiesToSummary(vulnerableChanges, minSeverity) 149 | issueFound ||= await printVulnerabilitiesBlock( 150 | vulnerableChanges, 151 | minSeverity, 152 | warnOnly 153 | ) 154 | } 155 | if (config.license_check) { 156 | core.setOutput( 157 | 'invalid-license-changes', 158 | JSON.stringify(invalidLicenseChanges) 159 | ) 160 | summary.addLicensesToSummary(invalidLicenseChanges, config) 161 | issueFound ||= await printLicensesBlock(invalidLicenseChanges, warnOnly) 162 | } 163 | if (config.deny_packages || config.deny_groups) { 164 | core.setOutput('denied-changes', JSON.stringify(deniedChanges)) 165 | summary.addDeniedToSummary(deniedChanges) 166 | issueFound ||= await printDeniedDependencies(deniedChanges, config) 167 | } 168 | if (config.show_openssf_scorecard) { 169 | summary.addScorecardToSummary(scorecard, config) 170 | printScorecardBlock(scorecard, config) 171 | createScorecardWarnings(scorecard, config) 172 | } 173 | 174 | core.setOutput('dependency-changes', JSON.stringify(changes)) 175 | summary.addScannedFiles(changes) 176 | printScannedDependencies(changes) 177 | 178 | // include full summary in output; Actions will truncate if oversized 179 | let rendered = core.summary.stringify() 180 | core.setOutput('comment-content', rendered) 181 | 182 | // if the summary is oversized, replace with minimal version 183 | if (rendered.length >= MAX_COMMENT_LENGTH) { 184 | core.debug( 185 | 'The comment was too big for the GitHub API. Falling back on a minimum comment' 186 | ) 187 | rendered = minSummary 188 | } 189 | 190 | // update the PR comment if needed with the right-sized summary 191 | await commentPr(rendered, config, issueFound) 192 | } catch (error) { 193 | if (error instanceof RequestError && error.status === 404) { 194 | core.setFailed( 195 | `Dependency review could not obtain dependency data for the specified owner, repository, or revision range.` 196 | ) 197 | } else if (error instanceof RequestError && error.status === 403) { 198 | core.setFailed( 199 | `Dependency review is not supported on this repository. Please ensure that Dependency graph is enabled along with GitHub Advanced Security on private repositories, see ${github.context.serverUrl}/${github.context.repo.owner}/${github.context.repo.repo}/settings/security_analysis` 200 | ) 201 | } else { 202 | if (error instanceof Error) { 203 | core.setFailed(error.message) 204 | } else { 205 | core.setFailed('Unexpected fatal error') 206 | } 207 | } 208 | } finally { 209 | await core.summary.write() 210 | } 211 | } 212 | 213 | async function printVulnerabilitiesBlock( 214 | addedChanges: Changes, 215 | minSeverity: Severity, 216 | warnOnly: boolean 217 | ): Promise { 218 | return core.group('Vulnerabilities', async () => { 219 | let vulFound = false 220 | 221 | for (const change of addedChanges) { 222 | vulFound ||= printChangeVulnerabilities(change) 223 | } 224 | 225 | if (vulFound) { 226 | const msg = 'Dependency review detected vulnerable packages.' 227 | if (warnOnly) { 228 | core.warning(msg) 229 | } else { 230 | core.setFailed(msg) 231 | } 232 | } else { 233 | core.info( 234 | `Dependency review did not detect any vulnerable packages with severity level "${minSeverity}" or higher.` 235 | ) 236 | } 237 | 238 | return vulFound 239 | }) 240 | } 241 | 242 | function printChangeVulnerabilities(change: Change): boolean { 243 | for (const vuln of change.vulnerabilities) { 244 | core.info( 245 | `${styles.bold.open}${change.manifest} » ${change.name}@${ 246 | change.version 247 | }${styles.bold.close} – ${vuln.advisory_summary} ${renderSeverity( 248 | vuln.severity 249 | )}` 250 | ) 251 | core.info(` ↪ ${vuln.advisory_url}`) 252 | } 253 | return change.vulnerabilities.length > 0 254 | } 255 | 256 | async function printLicensesBlock( 257 | invalidLicenseChanges: Record, 258 | warnOnly: boolean 259 | ): Promise { 260 | return core.group('Licenses', async () => { 261 | let issueFound = false 262 | 263 | if (invalidLicenseChanges.forbidden.length > 0) { 264 | issueFound = true 265 | core.info('\nThe following dependencies have incompatible licenses:') 266 | printLicensesError(invalidLicenseChanges.forbidden) 267 | const msg = 'Dependency review detected incompatible licenses.' 268 | if (warnOnly) { 269 | core.warning(msg) 270 | } else { 271 | core.setFailed(msg) 272 | } 273 | } 274 | if (invalidLicenseChanges.unresolved.length > 0) { 275 | issueFound = true 276 | core.warning( 277 | '\nThe validity of the licenses of the dependencies below could not be determined. Ensure that they are valid SPDX licenses:' 278 | ) 279 | printLicensesError(invalidLicenseChanges.unresolved) 280 | core.setFailed( 281 | 'Dependency review could not detect the validity of all licenses.' 282 | ) 283 | } 284 | printNullLicenses(invalidLicenseChanges.unlicensed) 285 | 286 | return issueFound 287 | }) 288 | } 289 | 290 | function printLicensesError(changes: Changes): void { 291 | for (const change of changes) { 292 | core.info( 293 | `${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close} – License: ${styles.color.red.open}${change.license}${styles.color.red.close}` 294 | ) 295 | } 296 | } 297 | 298 | function printNullLicenses(changes: Changes): void { 299 | if (changes.length === 0) { 300 | return 301 | } 302 | 303 | core.info('\nWe could not detect a license for the following dependencies:') 304 | for (const change of changes) { 305 | core.info( 306 | `${styles.bold.open}${change.manifest} » ${change.name}@${change.version}${styles.bold.close}` 307 | ) 308 | } 309 | } 310 | 311 | function printScorecardBlock( 312 | scorecard: Scorecard, 313 | config: ConfigurationOptions 314 | ): void { 315 | core.group('Scorecard', async () => { 316 | if (scorecard) { 317 | for (const dependency of scorecard.dependencies) { 318 | if ( 319 | dependency.scorecard?.score && 320 | dependency.scorecard?.score < config.warn_on_openssf_scorecard_level 321 | ) { 322 | core.info( 323 | `${styles.color.red.open}${dependency.change.ecosystem}/${dependency.change.name}: OpenSSF Scorecard Score: ${dependency?.scorecard?.score}${styles.red.close}` 324 | ) 325 | } 326 | core.info( 327 | `${dependency.change.ecosystem}/${dependency.change.name}: OpenSSF Scorecard Score: ${dependency?.scorecard?.score}` 328 | ) 329 | } 330 | } 331 | }) 332 | } 333 | 334 | function renderSeverity( 335 | severity: 'critical' | 'high' | 'moderate' | 'low' 336 | ): string { 337 | const color = ( 338 | { 339 | critical: 'red', 340 | high: 'red', 341 | moderate: 'yellow', 342 | low: 'grey' 343 | } as const 344 | )[severity] 345 | return `${styles.color[color].open}(${severity} severity)${styles.color[color].close}` 346 | } 347 | 348 | function renderScannedDependency(change: Change): string { 349 | const changeType: string = change.change_type 350 | 351 | if (changeType !== 'added' && changeType !== 'removed') { 352 | throw new Error(`Unexpected change type: ${changeType}`) 353 | } 354 | 355 | const color = ( 356 | { 357 | added: 'green', 358 | removed: 'red' 359 | } as const 360 | )[changeType] 361 | 362 | const icon = ( 363 | { 364 | added: '+', 365 | removed: '-' 366 | } as const 367 | )[changeType] 368 | 369 | return `${styles.color[color].open}${icon} ${change.name}@${change.version}${styles.color[color].close}` 370 | } 371 | 372 | function printScannedDependencies(changes: Changes): void { 373 | core.group('Dependency Changes', async () => { 374 | const dependencies = groupDependenciesByManifest(changes) 375 | 376 | for (const manifestName of dependencies.keys()) { 377 | const manifestChanges = dependencies.get(manifestName) || [] 378 | core.info(`File: ${styles.bold.open}${manifestName}${styles.bold.close}`) 379 | for (const change of manifestChanges) { 380 | core.info(`${renderScannedDependency(change)}`) 381 | } 382 | } 383 | }) 384 | } 385 | 386 | async function printDeniedDependencies( 387 | changes: Changes, 388 | config: ConfigurationOptions 389 | ): Promise { 390 | return core.group('Denied', async () => { 391 | let issueFound = false 392 | 393 | for (const denied of config.deny_packages) { 394 | core.info(`Config: ${denied}`) 395 | } 396 | 397 | for (const change of changes) { 398 | core.info(`Change: ${change.name}@${change.version} is denied`) 399 | core.info(`Change: ${change.package_url} is denied`) 400 | } 401 | 402 | if (changes.length > 0) { 403 | issueFound = true 404 | core.setFailed('Dependency review detected denied packages.') 405 | } else { 406 | core.info('Dependency review did not detect any denied packages') 407 | } 408 | 409 | return issueFound 410 | }) 411 | } 412 | 413 | function getScorecardChanges(changes: Changes): Changes { 414 | const out: Changes = [] 415 | for (const change of changes) { 416 | if (change.change_type === 'added') { 417 | out.push(change) 418 | } 419 | } 420 | 421 | return out 422 | } 423 | 424 | async function createScorecardWarnings( 425 | scorecards: Scorecard, 426 | config: ConfigurationOptions 427 | ): Promise { 428 | // Iterate through the list of scorecards, and if the score is less than the threshold, send a warning 429 | for (const dependency of scorecards.dependencies) { 430 | if ( 431 | dependency.scorecard?.score && 432 | dependency.scorecard?.score < config.warn_on_openssf_scorecard_level 433 | ) { 434 | core.warning( 435 | `${dependency.change.ecosystem}/${dependency.change.name} has an OpenSSF Scorecard of ${dependency.scorecard?.score}, which is less than this repository's threshold of ${config.warn_on_openssf_scorecard_level}.`, 436 | { 437 | title: 'OpenSSF Scorecard Warning' 438 | } 439 | ) 440 | } 441 | } 442 | } 443 | 444 | run() 445 | -------------------------------------------------------------------------------- /src/purl.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | 3 | // the basic purl type, containing type, namespace, name, and version. 4 | // other than type, all fields are nullable. this is for maximum flexibility 5 | // at the cost of strict adherence to the package-url spec. 6 | export const PurlSchema = z.object({ 7 | type: z.string(), 8 | namespace: z.string().nullable(), 9 | name: z.string().nullable(), // name is nullable for deny-groups 10 | version: z.string().nullable(), 11 | original: z.string(), 12 | error: z.string().nullable() 13 | }) 14 | 15 | export type PackageURL = z.infer 16 | 17 | const PURL_TYPE = /pkg:([a-zA-Z0-9-_]+)\/.*/ 18 | 19 | export function parsePURL(purl: string): PackageURL { 20 | const result: PackageURL = { 21 | type: '', 22 | namespace: null, 23 | name: null, 24 | version: null, 25 | original: purl, 26 | error: null 27 | } 28 | if (!purl.startsWith('pkg:')) { 29 | result.error = 'package-url must start with "pkg:"' 30 | return result 31 | } 32 | const type = purl.match(PURL_TYPE) 33 | if (!type) { 34 | result.error = 'package-url must contain a type' 35 | return result 36 | } 37 | result.type = type[1] 38 | const parts = purl.split('/') 39 | // the first 'part' should be 'pkg:ecosystem' 40 | if (parts.length < 2 || !parts[1]) { 41 | result.error = 'package-url must contain a namespace or name' 42 | return result 43 | } 44 | let namePlusRest: string 45 | if (parts.length === 2) { 46 | namePlusRest = parts[1] 47 | } else { 48 | result.namespace = decodeURIComponent(parts[1]) 49 | // Add back the '/'s to the rest of the parts, in case there are any more. 50 | // This may violate the purl spec, but people do it and it can be parsed 51 | // without ambiguity. 52 | namePlusRest = parts.slice(2).join('/') 53 | } 54 | const name = namePlusRest.match(/([^@#?]+)[@#?]?.*/) 55 | if (!result.namespace && !name) { 56 | result.error = 'package-url must contain a namespace or name' 57 | return result 58 | } 59 | if (!name) { 60 | // we're done here 61 | return result 62 | } 63 | result.name = decodeURIComponent(name[1]) 64 | const version = namePlusRest.match(/@([^#?]+)[#?]?.*/) 65 | if (!version) { 66 | return result 67 | } 68 | result.version = decodeURIComponent(version[1]) 69 | 70 | // we don't parse subpath or attributes, so we're done here 71 | return result 72 | } 73 | -------------------------------------------------------------------------------- /src/schemas.ts: -------------------------------------------------------------------------------- 1 | import * as z from 'zod' 2 | import {parsePURL} from './purl' 3 | 4 | export const SEVERITIES = ['critical', 'high', 'moderate', 'low'] as const 5 | export const SCOPES = ['unknown', 'runtime', 'development'] as const 6 | 7 | export const SeveritySchema = z.enum(SEVERITIES).default('low') 8 | 9 | const PackageURL = z 10 | .string() 11 | .transform(purlString => { 12 | return parsePURL(purlString) 13 | }) 14 | .superRefine((purl, context) => { 15 | if (purl.error) { 16 | context.addIssue({ 17 | code: z.ZodIssueCode.custom, 18 | message: `Error parsing package-url: ${purl.error}` 19 | }) 20 | } 21 | if (!purl.name) { 22 | context.addIssue({ 23 | code: z.ZodIssueCode.custom, 24 | message: `Error parsing package-url: name is required` 25 | }) 26 | } 27 | }) 28 | 29 | const PackageURLWithNamespace = z 30 | .string() 31 | .transform(purlString => { 32 | return parsePURL(purlString) 33 | }) 34 | .superRefine((purl, context) => { 35 | if (purl.error) { 36 | context.addIssue({ 37 | code: z.ZodIssueCode.custom, 38 | message: `Error parsing purl: ${purl.error}` 39 | }) 40 | } 41 | if (purl.namespace === null) { 42 | context.addIssue({ 43 | code: z.ZodIssueCode.custom, 44 | message: `package-url must have a namespace, and the namespace must be followed by '/'` 45 | }) 46 | } 47 | }) 48 | 49 | const PackageURLString = z.string().superRefine((value, context) => { 50 | const purl = parsePURL(value) 51 | if (purl.error) { 52 | context.addIssue({ 53 | code: z.ZodIssueCode.custom, 54 | message: `Error parsing package-url: ${purl.error}` 55 | }) 56 | } 57 | if (!purl.name) { 58 | context.addIssue({ 59 | code: z.ZodIssueCode.custom, 60 | message: `Error parsing package-url: name is required` 61 | }) 62 | } 63 | }) 64 | 65 | export const ChangeSchema = z.object({ 66 | change_type: z.enum(['added', 'removed']), 67 | manifest: z.string(), 68 | ecosystem: z.string(), 69 | name: z.string(), 70 | version: z.string(), 71 | package_url: z.string(), 72 | license: z.string().nullable(), 73 | source_repository_url: z.string().nullable(), 74 | scope: z.enum(SCOPES).optional(), 75 | vulnerabilities: z 76 | .array( 77 | z.object({ 78 | severity: SeveritySchema, 79 | advisory_ghsa_id: z.string(), 80 | advisory_summary: z.string(), 81 | advisory_url: z.string() 82 | }) 83 | ) 84 | .optional() 85 | .default([]) 86 | }) 87 | 88 | export const PullRequestSchema = z.object({ 89 | number: z.number(), 90 | base: z.object({sha: z.string()}), 91 | head: z.object({sha: z.string()}) 92 | }) 93 | 94 | export const MergeGroupSchema = z.object({ 95 | base_sha: z.string(), 96 | head_sha: z.string() 97 | }) 98 | 99 | export const ConfigurationOptionsSchema = z 100 | .object({ 101 | fail_on_severity: SeveritySchema, 102 | fail_on_scopes: z.array(z.enum(SCOPES)).default(['runtime']), 103 | allow_licenses: z.array(z.string()).optional(), 104 | deny_licenses: z.array(z.string()).optional(), 105 | allow_dependencies_licenses: z.array(PackageURLString).optional(), 106 | allow_ghsas: z.array(z.string()).default([]), 107 | deny_packages: z.array(PackageURL).default([]), 108 | deny_groups: z.array(PackageURLWithNamespace).default([]), 109 | license_check: z.boolean().default(true), 110 | vulnerability_check: z.boolean().default(true), 111 | config_file: z.string().optional(), 112 | base_ref: z.string().optional(), 113 | head_ref: z.string().optional(), 114 | retry_on_snapshot_warnings: z.boolean().default(false), 115 | retry_on_snapshot_warnings_timeout: z.number().default(120), 116 | show_openssf_scorecard: z.boolean().optional().default(true), 117 | warn_on_openssf_scorecard_level: z.number().default(3), 118 | comment_summary_in_pr: z 119 | .union([ 120 | z.preprocess( 121 | val => (val === 'true' ? true : val === 'false' ? false : val), 122 | z.boolean() 123 | ), 124 | z.enum(['always', 'never', 'on-failure']) 125 | ]) 126 | .default('never'), 127 | warn_only: z.boolean().default(false) 128 | }) 129 | .transform(config => { 130 | if (config.comment_summary_in_pr === true) { 131 | config.comment_summary_in_pr = 'always' 132 | } else if (config.comment_summary_in_pr === false) { 133 | config.comment_summary_in_pr = 'never' 134 | } 135 | return config 136 | }) 137 | .superRefine((config, context) => { 138 | if (config.allow_licenses && config.deny_licenses) { 139 | context.addIssue({ 140 | code: z.ZodIssueCode.custom, 141 | message: 'You cannot specify both allow-licenses and deny-licenses' 142 | }) 143 | } 144 | if (config.allow_licenses && config.allow_licenses.length < 1) { 145 | context.addIssue({ 146 | code: z.ZodIssueCode.custom, 147 | message: 'You should provide at least one license in allow-licenses' 148 | }) 149 | } 150 | if ( 151 | config.license_check === false && 152 | config.vulnerability_check === false 153 | ) { 154 | context.addIssue({ 155 | code: z.ZodIssueCode.custom, 156 | message: "Can't disable both license-check and vulnerability-check" 157 | }) 158 | } 159 | }) 160 | 161 | export const ChangesSchema = z.array(ChangeSchema) 162 | export const ComparisonResponseSchema = z.object({ 163 | changes: z.array(ChangeSchema), 164 | snapshot_warnings: z.string() 165 | }) 166 | 167 | export const ScorecardApiSchema = z.object({ 168 | date: z.string(), 169 | repo: z 170 | .object({ 171 | name: z.string(), 172 | commit: z.string() 173 | }) 174 | .nullish(), 175 | scorecard: z 176 | .object({ 177 | version: z.string(), 178 | commit: z.string() 179 | }) 180 | .nullish(), 181 | checks: z 182 | .array( 183 | z.object({ 184 | name: z.string(), 185 | documentation: z.object({ 186 | shortDescription: z.string(), 187 | url: z.string() 188 | }), 189 | score: z.string(), 190 | reason: z.string(), 191 | details: z.array(z.string()) 192 | }) 193 | ) 194 | .nullish(), 195 | score: z.number().nullish() 196 | }) 197 | 198 | export const ScorecardSchema = z.object({ 199 | dependencies: z.array( 200 | z.object({ 201 | change: ChangeSchema, 202 | scorecard: ScorecardApiSchema.nullish() 203 | }) 204 | ) 205 | }) 206 | 207 | export type Change = z.infer 208 | export type Changes = z.infer 209 | export type ComparisonResponse = z.infer 210 | export type ConfigurationOptions = z.infer 211 | export type Severity = z.infer 212 | export type Scope = (typeof SCOPES)[number] 213 | export type Scorecard = z.infer 214 | export type ScorecardApi = z.infer 215 | -------------------------------------------------------------------------------- /src/scorecard.ts: -------------------------------------------------------------------------------- 1 | import {Change, Scorecard, ScorecardApi} from './schemas' 2 | import * as core from '@actions/core' 3 | 4 | export async function getScorecardLevels( 5 | changes: Change[] 6 | ): Promise { 7 | const data: Scorecard = {dependencies: []} as Scorecard 8 | for (const change of changes) { 9 | const ecosystem = change.ecosystem 10 | const packageName = change.name 11 | const version = change.version 12 | 13 | //Get the project repository 14 | let repositoryUrl = change.source_repository_url 15 | //If the repository_url includes the protocol, remove it 16 | if (repositoryUrl?.startsWith('https://')) { 17 | repositoryUrl = repositoryUrl.replace('https://', '') 18 | } 19 | 20 | // Handle the special case for GitHub Actions, where the repository URL is null 21 | if (ecosystem === 'actions') { 22 | // The package name for GitHub Actions in the API is in the format `owner/repo/`, so we can use that to get the repository URL 23 | // If the package name has more than 2 slashes, it's referencing a sub-action, and we need to strip the last part out 24 | const parts = packageName.split('/') 25 | repositoryUrl = `github.com/${parts[0]}/${parts[1]}` // e.g. github.com/actions/checkout 26 | } 27 | 28 | // If GitHub API doesn't have the repository URL, query deps.dev for it. 29 | if (!repositoryUrl) { 30 | // Call the deps.dev API to get the repository URL from there 31 | repositoryUrl = await getProjectUrl(ecosystem, packageName, version) 32 | } 33 | 34 | // Get the scorecard API response from the scorecards API 35 | let scorecardApi: ScorecardApi | null = null 36 | if (repositoryUrl) { 37 | try { 38 | scorecardApi = await getScorecard(repositoryUrl) 39 | } catch (error: unknown) { 40 | core.debug(`Error querying for scorecard: ${(error as Error).message}`) 41 | } 42 | } 43 | data.dependencies.push({ 44 | change, 45 | scorecard: scorecardApi 46 | }) 47 | } 48 | return data 49 | } 50 | 51 | async function getScorecard(repositoryUrl: string): Promise { 52 | const apiRoot = 'https://api.securityscorecards.dev' 53 | let scorecardResponse: ScorecardApi = {} as ScorecardApi 54 | 55 | const url = `${apiRoot}/projects/${repositoryUrl}` 56 | const response = await fetch(url) 57 | if (response.ok) { 58 | scorecardResponse = await response.json() 59 | } else { 60 | core.debug(`Couldn't get scorecard data for ${repositoryUrl}`) 61 | } 62 | return scorecardResponse 63 | } 64 | 65 | export async function getProjectUrl( 66 | ecosystem: string, 67 | packageName: string, 68 | version: string 69 | ): Promise { 70 | core.debug(`Getting deps.dev data for ${packageName} ${version}`) 71 | const depsDevAPIRoot = 'https://api.deps.dev' 72 | const url = `${depsDevAPIRoot}/v3/systems/${ecosystem}/packages/${packageName}/versions/${version}` 73 | const response = await fetch(url) 74 | if (response.ok) { 75 | const data = await response.json() 76 | if (data.relatedProjects.length > 0) { 77 | return data.relatedProjects[0].projectKey.id 78 | } 79 | } 80 | return '' 81 | } 82 | -------------------------------------------------------------------------------- /src/spdx-satisfies.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'spdx-satisfies' { 2 | function spdxSatisfies(candidate: string, allowList: string[]): boolean 3 | export = spdxSatisfies 4 | } 5 | -------------------------------------------------------------------------------- /src/spdx.ts: -------------------------------------------------------------------------------- 1 | import * as spdxlib from '@onebeyond/spdx-license-satisfies' 2 | import spdxSatisfies from 'spdx-satisfies' 3 | import parse from 'spdx-expression-parse' 4 | 5 | /* 6 | * NOTE: spdx-license-satisfies methods depend on spdx-expression-parse 7 | * which throws errors in the presence of any syntax trouble, unknown 8 | * license tokens, case sensitivity problems etc. to simplify handling 9 | * you should pre-screen inputs to the satisfies* methods using isValid 10 | */ 11 | 12 | // accepts a pair of well-formed SPDX expressions. the 13 | // candidate is tested against the constraint 14 | export function satisfies(candidateExpr: string, allowList: string[]): boolean { 15 | candidateExpr = cleanInvalidSPDX(candidateExpr) 16 | try { 17 | return spdxSatisfies(candidateExpr, allowList) 18 | } catch (_) { 19 | return false 20 | } 21 | } 22 | 23 | // accepts an SPDX expression and a non-empty list of licenses (not expressions) 24 | export function satisfiesAny( 25 | candidateExpr: string, 26 | licenses: string[] 27 | ): boolean { 28 | candidateExpr = cleanInvalidSPDX(candidateExpr) 29 | try { 30 | return spdxlib.satisfiesAny(candidateExpr, licenses) 31 | } catch (_) { 32 | return false 33 | } 34 | } 35 | 36 | // accepts an SPDX expression and a non-empty list of licenses (not expressions) 37 | export function satisfiesAll( 38 | candidateExpr: string, 39 | licenses: string[] 40 | ): boolean { 41 | candidateExpr = cleanInvalidSPDX(candidateExpr) 42 | try { 43 | return spdxlib.satisfiesAll(candidateExpr, licenses) 44 | } catch (_) { 45 | return false 46 | } 47 | } 48 | 49 | // accepts any SPDX expression 50 | export function isValid(spdxExpr: string): boolean { 51 | spdxExpr = cleanInvalidSPDX(spdxExpr) 52 | try { 53 | parse(spdxExpr) 54 | return true 55 | } catch (_) { 56 | return false 57 | } 58 | } 59 | 60 | const replaceOtherRegex = /(? 0 83 | ? [ 84 | `${checkOrWarnIcon(deniedChanges.length)} ${ 85 | deniedChanges.length 86 | } package(s) denied.` 87 | ] 88 | : []), 89 | ...(config.show_openssf_scorecard && scorecardWarnings > 0 90 | ? [ 91 | `${checkOrWarnIcon(scorecardWarnings)} ${scorecardWarnings ? scorecardWarnings : 'No'} packages with OpenSSF Scorecard issues.` 92 | ] 93 | : []) 94 | ] 95 | 96 | core.summary.addList(summaryList) 97 | for (const line of summaryList) { 98 | out.push(`* ${line}`) 99 | } 100 | 101 | core.summary.addRaw('See the Details below.') 102 | out.push( 103 | `\n[View full job summary](${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID})` 104 | ) 105 | 106 | return out.join('\n') 107 | } 108 | 109 | function countScorecardWarnings( 110 | scorecard: Scorecard, 111 | config: ConfigurationOptions 112 | ): number { 113 | return scorecard.dependencies.reduce( 114 | (total, dependency) => 115 | total + 116 | (dependency.scorecard?.score && 117 | dependency.scorecard?.score < config.warn_on_openssf_scorecard_level 118 | ? 1 119 | : 0), 120 | 0 121 | ) 122 | } 123 | 124 | export function addChangeVulnerabilitiesToSummary( 125 | vulnerableChanges: Changes, 126 | severity: string 127 | ): void { 128 | if (vulnerableChanges.length === 0) { 129 | return 130 | } 131 | 132 | const rows: SummaryTableRow[] = [] 133 | 134 | const manifests = getManifestsSet(vulnerableChanges) 135 | 136 | core.summary.addHeading('Vulnerabilities', 2) 137 | 138 | for (const manifest of manifests) { 139 | for (const change of vulnerableChanges.filter( 140 | pkg => pkg.manifest === manifest 141 | )) { 142 | let previous_package = '' 143 | let previous_version = '' 144 | for (const vuln of change.vulnerabilities) { 145 | const sameAsPrevious = 146 | previous_package === change.name && 147 | previous_version === change.version 148 | 149 | if (!sameAsPrevious) { 150 | rows.push([ 151 | renderUrl(change.source_repository_url, change.name), 152 | change.version, 153 | renderUrl(vuln.advisory_url, vuln.advisory_summary), 154 | vuln.severity 155 | ]) 156 | } else { 157 | rows.push([ 158 | {data: '', colspan: '2'}, 159 | renderUrl(vuln.advisory_url, vuln.advisory_summary), 160 | vuln.severity 161 | ]) 162 | } 163 | previous_package = change.name 164 | previous_version = change.version 165 | } 166 | } 167 | core.summary.addHeading(`${manifest}`, 4).addTable([ 168 | [ 169 | {data: 'Name', header: true}, 170 | {data: 'Version', header: true}, 171 | {data: 'Vulnerability', header: true}, 172 | {data: 'Severity', header: true} 173 | ], 174 | ...rows 175 | ]) 176 | } 177 | 178 | if (severity !== 'low') { 179 | core.summary.addQuote( 180 | `Only included vulnerabilities with severity ${severity} or higher.` 181 | ) 182 | } 183 | } 184 | 185 | export function addLicensesToSummary( 186 | invalidLicenseChanges: InvalidLicenseChanges, 187 | config: ConfigurationOptions 188 | ): void { 189 | if (countLicenseIssues(invalidLicenseChanges) === 0) { 190 | return 191 | } 192 | 193 | core.summary.addHeading('License Issues', 2) 194 | printLicenseViolations(invalidLicenseChanges) 195 | 196 | if (config.allow_licenses && config.allow_licenses.length > 0) { 197 | core.summary.addQuote( 198 | `Allowed Licenses: ${config.allow_licenses.join(', ')}` 199 | ) 200 | } 201 | if (config.deny_licenses && config.deny_licenses.length > 0) { 202 | core.summary.addQuote( 203 | `Denied Licenses: ${config.deny_licenses.join(', ')}` 204 | ) 205 | } 206 | if (config.allow_dependencies_licenses) { 207 | core.summary.addQuote( 208 | `Excluded from license check: ${config.allow_dependencies_licenses.join( 209 | ', ' 210 | )}` 211 | ) 212 | } 213 | 214 | core.debug( 215 | `found ${invalidLicenseChanges.unlicensed.length} unknown licenses` 216 | ) 217 | 218 | core.debug( 219 | `${invalidLicenseChanges.unresolved.length} licenses could not be validated` 220 | ) 221 | } 222 | 223 | const licenseIssueTypes: InvalidLicenseChangeTypes[] = [ 224 | 'forbidden', 225 | 'unresolved', 226 | 'unlicensed' 227 | ] 228 | 229 | const issueTypeNames: Record = { 230 | forbidden: 'Incompatible License', 231 | unresolved: 'Invalid SPDX License', 232 | unlicensed: 'Unknown License' 233 | } 234 | 235 | function printLicenseViolations(changes: InvalidLicenseChanges): void { 236 | const rowsGroupedByManifest: Record = {} 237 | 238 | for (const issueType of licenseIssueTypes) { 239 | for (const change of changes[issueType]) { 240 | if (!rowsGroupedByManifest[change.manifest]) { 241 | rowsGroupedByManifest[change.manifest] = [] 242 | } 243 | rowsGroupedByManifest[change.manifest].push([ 244 | renderUrl(change.source_repository_url, change.name), 245 | change.version, 246 | formatLicense(change.license), 247 | issueTypeNames[issueType] 248 | ]) 249 | } 250 | } 251 | 252 | for (const [manifest, rows] of Object.entries(rowsGroupedByManifest)) { 253 | core.summary.addHeading(`${manifest}`, 4) 254 | core.summary.addTable([ 255 | ['Package', 'Version', 'License', 'Issue Type'], 256 | ...rows 257 | ]) 258 | } 259 | } 260 | 261 | function formatLicense(license: string | null): string { 262 | if (license === null || license === 'NOASSERTION') { 263 | return 'Null' 264 | } 265 | return license 266 | } 267 | 268 | export function addScannedFiles(changes: Changes): void { 269 | const manifests = Array.from( 270 | groupDependenciesByManifest(changes).keys() 271 | ).sort() 272 | 273 | let sf_size = 0 274 | let trunc_at = -1 275 | 276 | for (const [index, entry] of manifests.entries()) { 277 | if (sf_size + entry.length >= MAX_SCANNED_FILES_BYTES) { 278 | trunc_at = index 279 | break 280 | } 281 | sf_size += entry.length 282 | } 283 | 284 | if (trunc_at >= 0) { 285 | // truncate the manifests list if it will overflow the summary output 286 | manifests.slice(0, trunc_at) 287 | // if there's room between cutoff size and list size, add a warning 288 | const size_diff = MAX_SCANNED_FILES_BYTES - sf_size 289 | if (size_diff < 12) { 290 | manifests.push('(truncated)') 291 | } 292 | } 293 | 294 | const summary = core.summary.addHeading('Scanned Files', 2) 295 | if (manifests.length === 0) { 296 | summary.addRaw('None') 297 | } else { 298 | summary.addList(manifests) 299 | } 300 | } 301 | 302 | function snapshotWarningRecommendation( 303 | config: ConfigurationOptions, 304 | warnings: string 305 | ): string { 306 | const no_pr_snaps = warnings.includes( 307 | 'No snapshots were found for the head SHA' 308 | ) 309 | const retries_disabled = !config.retry_on_snapshot_warnings 310 | if (no_pr_snaps && retries_disabled) { 311 | return 'Ensure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings.' 312 | } else if (no_pr_snaps) { 313 | return 'Ensure that dependencies are being submitted on PR branches. Re-running this action after a short time may resolve the issue.' 314 | } else if (retries_disabled) { 315 | return 'Consider enabling retry-on-snapshot-warnings.' 316 | } 317 | return 'Re-running this action after a short time may resolve the issue.' 318 | } 319 | 320 | export function addScorecardToSummary( 321 | scorecard: Scorecard, 322 | config: ConfigurationOptions 323 | ): void { 324 | if (scorecard.dependencies.length === 0) { 325 | return 326 | } 327 | core.summary.addHeading('OpenSSF Scorecard', 2) 328 | if (scorecard.dependencies.length > 10) { 329 | core.summary.addRaw(`
Scorecard details`, true) 330 | } 331 | core.summary.addRaw( 332 | ``, 333 | true 334 | ) 335 | for (const dependency of scorecard.dependencies) { 336 | core.debug('Adding scorecard to summary') 337 | core.debug(`Overall score ${dependency.scorecard?.score}`) 338 | 339 | // Set the icon based on the overall score value 340 | let overallIcon = '' 341 | if (dependency.scorecard?.score) { 342 | overallIcon = 343 | dependency.scorecard?.score < config.warn_on_openssf_scorecard_level 344 | ? ':warning:' 345 | : ':green_circle:' 346 | } 347 | 348 | //Add a row for the dependency 349 | core.summary.addRaw( 350 | ` 351 | `, 352 | false 353 | ) 354 | 355 | //Add details table in the last column 356 | if (dependency.scorecard?.checks !== undefined) { 357 | let detailsTable = 358 | '
PackageVersionScoreDetails
${dependency.change.source_repository_url ? `` : ''} ${dependency.change.ecosystem}/${dependency.change.name} ${dependency.change.source_repository_url ? `` : ''}${dependency.change.version}${overallIcon} ${dependency.scorecard?.score === undefined || dependency.scorecard?.score === null ? 'Unknown' : dependency.scorecard?.score}
' 359 | for (const check of dependency.scorecard?.checks || []) { 360 | const icon = 361 | parseFloat(check.score) < config.warn_on_openssf_scorecard_level 362 | ? ':warning:' 363 | : ':green_circle:' 364 | 365 | detailsTable += `` 366 | } 367 | detailsTable += `
CheckScoreReason
${check.name}${icon} ${check.score}${check.reason}
` 368 | core.summary.addRaw( 369 | `
Details${detailsTable}
`, 370 | true 371 | ) 372 | } else { 373 | core.summary.addRaw('Unknown', true) 374 | } 375 | } 376 | core.summary.addRaw(``) 377 | if (scorecard.dependencies.length > 10) { 378 | core.summary.addRaw(`
`) 379 | } 380 | } 381 | 382 | export function addSnapshotWarnings( 383 | config: ConfigurationOptions, 384 | warnings: string 385 | ): void { 386 | core.summary.addHeading('Snapshot Warnings', 2) 387 | core.summary.addQuote(`${icons.warning}: ${warnings}`) 388 | const recommendation = snapshotWarningRecommendation(config, warnings) 389 | const docsLink = 390 | 'See the documentation for more information and troubleshooting advice.' 391 | core.summary.addRaw(`${recommendation} ${docsLink}`) 392 | } 393 | 394 | function countLicenseIssues( 395 | invalidLicenseChanges: InvalidLicenseChanges 396 | ): number { 397 | return Object.values(invalidLicenseChanges).reduce( 398 | (acc, val) => acc + val.length, 399 | 0 400 | ) 401 | } 402 | 403 | export function addDeniedToSummary(deniedChanges: Change[]): void { 404 | if (deniedChanges.length === 0) { 405 | return 406 | } 407 | 408 | core.summary.addHeading('Denied dependencies', 2) 409 | for (const change of deniedChanges) { 410 | core.summary.addHeading(`Denied dependencies`, 4) 411 | core.summary.addTable([ 412 | ['Package', 'Version', 'License'], 413 | [ 414 | renderUrl(change.source_repository_url, change.name), 415 | change.version, 416 | change.license || '' 417 | ] 418 | ]) 419 | } 420 | } 421 | 422 | function checkOrFailIcon(count: number): string { 423 | return count === 0 ? icons.check : icons.cross 424 | } 425 | 426 | function checkOrWarnIcon(count: number): string { 427 | return count === 0 ? icons.check : icons.warning 428 | } 429 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core' 2 | import {Octokit} from 'octokit' 3 | import {Changes} from './schemas' 4 | 5 | export function groupDependenciesByManifest( 6 | changes: Changes 7 | ): Map { 8 | const dependencies: Map = new Map() 9 | for (const change of changes) { 10 | // If the manifest is null or empty, give it a name now to avoid 11 | // breaking the HTML rendering later 12 | const manifestName = change.manifest || 'Unnamed Manifest' 13 | 14 | if (dependencies.get(manifestName) === undefined) { 15 | dependencies.set(manifestName, []) 16 | } 17 | 18 | dependencies.get(manifestName)?.push(change) 19 | } 20 | 21 | return dependencies 22 | } 23 | 24 | export function getManifestsSet(changes: Changes): Set { 25 | return new Set(changes.flatMap(c => c.manifest)) 26 | } 27 | 28 | export function renderUrl(url: string | null, text: string): string { 29 | if (url) { 30 | return `${text}` 31 | } else { 32 | return text 33 | } 34 | } 35 | 36 | function isEnterprise(): boolean { 37 | const serverUrl = new URL( 38 | process.env['GITHUB_SERVER_URL'] ?? 'https://github.com' 39 | ) 40 | return serverUrl.hostname.toLowerCase() !== 'github.com' 41 | } 42 | 43 | export function octokitClient(token = 'repo-token', required = true): Octokit { 44 | const opts: Record = {} 45 | 46 | // auth is only added if token is present. For remote config files in public 47 | // repos the token is optional, so it could be undefined. 48 | const auth = core.getInput(token, {required}) 49 | if (auth !== undefined) { 50 | opts['auth'] = auth 51 | } 52 | 53 | //baseUrl is required for GitHub Enterprise Server 54 | //https://github.com/octokit/octokit.js/blob/9c8fa89d5b0bc4ddbd6dec638db00a2f6c94c298/README.md?plain=1#L196 55 | if (isEnterprise()) { 56 | opts['baseUrl'] = new URL('api/v3', process.env['GITHUB_SERVER_URL']) 57 | } 58 | 59 | return new Octokit(opts) 60 | } 61 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "outDir": "./lib" /* Redirect output structure to the directory. */, 6 | "rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */, 4 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 5 | "outDir": "./lib" /* Redirect output structure to the directory. */, 6 | "strict": true /* Enable all strict type-checking options. */, 7 | "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 8 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 9 | "typeRoots": [ "./node_modules/@types", "./types" ], 10 | "types": [ "node", "jest", "spdx-license-satisfies" ] 11 | }, 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /types/spdx-license-satisfies.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@onebeyond/spdx-license-satisfies' { 2 | export function satisfies( 3 | candidateExpr: string, 4 | constraintExpr: string 5 | ): boolean 6 | 7 | export function satisfiesAny( 8 | candidateExpr: string, 9 | licenses: string[] 10 | ): boolean 11 | 12 | export function satisfiesAll( 13 | candidateExpr: string, 14 | licenses: string[] 15 | ): boolean 16 | } 17 | --------------------------------------------------------------------------------