├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── check_screenshot.png ├── issue_screenshot.png └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── input.ts ├── interfaces.ts ├── main.ts ├── reporter.ts └── templates.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | max_line_length = 80 8 | indent_size = 4 9 | 10 | [*.yml] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "parser": "@typescript-eslint/parser", 6 | "parserOptions": { 7 | "project": "./tsconfig.json" 8 | }, 9 | "plugins": [ 10 | "@typescript-eslint" 11 | ], 12 | "extends": [ 13 | "eslint:recommended", 14 | "plugin:@typescript-eslint/eslint-recommended", 15 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 16 | "plugin:@typescript-eslint/recommended", 17 | "plugin:prettier/recommended", 18 | "prettier", 19 | "prettier/@typescript-eslint" 20 | ], 21 | "rules": { 22 | "@typescript-eslint/explicit-function-return-type": 0 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | liberapay: svartalf 2 | patreon: svartalf 3 | custom: ["https://svartalf.info/donate/", "https://www.buymeacoffee.com/svartalf"] 4 | -------------------------------------------------------------------------------- /.github/check_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustsec/audit-check/dd2c7de31f710805e0ba233e31ad6b59d7e27e51/.github/check_screenshot.png -------------------------------------------------------------------------------- /.github/issue_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rustsec/audit-check/dd2c7de31f710805e0ba233e31ad6b59d7e27e51/.github/issue_screenshot.png -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: [pull_request, push] 4 | 5 | jobs: 6 | main: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Create npm configuration 10 | run: echo "//npm.pkg.github.com/:_authToken=${token}" >> ~/.npmrc 11 | env: 12 | token: ${{ secrets.GITHUB_TOKEN }} 13 | 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version-file: '.nvmrc' 18 | cache: 'npm' 19 | - run: npm ci 20 | # octokit types problem ? 21 | # - run: npm run lint 22 | - run: npm run build 23 | - run: npm run test 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __tests__/runner/* 2 | 3 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @clechasseur:registry=https://npm.pkg.github.com 2 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 4, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [1.4.1] - 2023-04-04 9 | 10 | ### Fixed 11 | 12 | - Corrected reporting on `unsound` and `notice` informationals 13 | 14 | ## [1.4.0] - 2023-04-04 15 | 16 | ### Fixed 17 | 18 | - Reflect change to enable warning on `unsound` and `notice` informationals 19 | 20 | ## [1.3.2] - 2023-03-13 21 | 22 | ### Changed 23 | 24 | - Update various dependencies to fix some known vulnerabilities. 25 | 26 | ## [1.3.1] - 2020-05-10 27 | 28 | ### Fixed 29 | 30 | - GitHub Actions does not support sequences as input 31 | 32 | ## [1.3.0] - 2022-05-09 33 | 34 | ### Added 35 | 36 | - Add support for ignores (#1) 37 | 38 | ## [1.2.0] - 2020-05-07 39 | 40 | ### Fixed 41 | 42 | - Compatibility with latest `cargo-audit == 0.12` JSON output (#115) 43 | - Do not fail check if no critical vulnerabilities were found when executed for a fork repository (closes #104) 44 | 45 | ## [1.1.0] 46 | 47 | ### Fixed 48 | 49 | - Invalid input properly terminates Action execution (#1) 50 | - Compatibility with new `cargo-audit` JSON output (#70) 51 | 52 | ## [1.0.0] - 2019-10-09 53 | 54 | ### Added 55 | 56 | - First public version 57 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2019 actions-rs team and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rust `audit-check` Action 2 | 3 | ![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg) 4 | 5 | > Security vulnerabilities audit 6 | 7 | This GitHub Action is using [cargo-audit](https://github.com/RustSec/cargo-audit) 8 | to perform an audit for crates with security vulnerabilities. 9 | 10 | ## Usage 11 | 12 | ### Audit changes 13 | 14 | We can utilize the GitHub Actions ability to execute workflow 15 | only if [the specific files were changed](https://help.github.com/en/articles/workflow-syntax-for-github-actions#onpushpull_requestpaths) 16 | and execute this Action to check the changed dependencies: 17 | 18 | ```yaml 19 | name: Security audit 20 | on: 21 | push: 22 | paths: 23 | - '**/Cargo.toml' 24 | - '**/Cargo.lock' 25 | jobs: 26 | security_audit: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v4 30 | - uses: rustsec/audit-check@v2.0.0 31 | with: 32 | token: ${{ secrets.GITHUB_TOKEN }} 33 | ``` 34 | 35 | It is recommended to add the `paths:` section into the workflow file, 36 | as it would effectively speed up the CI pipeline, since the audit process 37 | will not be performed if no dependencies were changed. 38 | 39 | 40 | In case of any security advisories found, [status check](https://help.github.com/en/articles/about-status-checks) 41 | created by this Action will be marked as "failed".\ 42 | Note that informational advisories are not affecting the check status. 43 | 44 | ![Check screenshot](.github/check_screenshot.png) 45 | 46 | #### Granular Permissions 47 | 48 | These are the typically used permissions: 49 | 50 | ```yaml 51 | name: 'rust-audit-check' 52 | github-token: 53 | action-input: 54 | input: token 55 | is-default: false 56 | permissions: 57 | issues: write 58 | issues-reason: to create issues 59 | checks: write 60 | checks-reason: to create check 61 | ``` 62 | 63 | The action does not raise issues when it is not triggered from a "cron" scheduled workflow. 64 | 65 | When running the action as scheduled it will crate issues but e.g. in PR / push fails the action. 66 | 67 | #### Limitations 68 | 69 | Due to [token permissions](https://help.github.com/en/articles/virtual-environments-for-github-actions#token-permissions), 70 | this Action **WILL NOT** be able to create Checks for Pull Requests from the forked repositories, 71 | see [actions-rs/clippy-check#2](https://github.com/actions-rs/clippy-check/issues/2) for details.\ 72 | As a fallback this Action will output all found advisories to the stdout.\ 73 | It is expected that this behavior will be fixed later by GitHub. 74 | 75 | ## Scheduled audit 76 | 77 | Another option is to use [`schedule`](https://help.github.com/en/articles/events-that-trigger-workflows#scheduled-events-schedule) event 78 | and execute this Action periodically against the `HEAD` of repository default branch. 79 | 80 | ```yaml 81 | name: Security audit 82 | on: 83 | schedule: 84 | - cron: '0 0 * * *' 85 | jobs: 86 | audit: 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: actions/checkout@v4 90 | - uses: rustsec/audit-check@v2.0.0 91 | with: 92 | token: ${{ secrets.GITHUB_TOKEN }} 93 | ``` 94 | 95 | With this example Action will be executed periodically at midnight of each day 96 | and check if there any new advisories appear for crate dependencies.\ 97 | For each new advisory (including informal) an issue will be created: 98 | 99 | ![Issue screenshot](.github/issue_screenshot.png) 100 | 101 | ## Inputs 102 | 103 | | Name | Required | Description | Type | Default | 104 | | ------------| -------- | ---------------------------------------------------------------------------| ------ | --------| 105 | | `token` | ✓ | [GitHub token], usually a `${{ secrets.GITHUB_TOKEN }}` | string | | 106 | | `ignore` | | Comma-separated list of advisory ids to ignore | string | | 107 | | `working-directory`| | The directory of the Cargo.toml / Cargo.lock files to scan. | string | `.` | 108 | 109 | [GitHub token]: https://help.github.com/en/actions/configuring-and-managing-workflows/authenticating-with-the-github_token 110 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'rust-audit-check' 2 | description: 'Run cargo audit and check for security advisories' 3 | author: 'actions-rs team' 4 | branding: 5 | icon: play-circle 6 | color: black 7 | inputs: 8 | token: 9 | description: GitHub Actions token 10 | required: true 11 | ignore: 12 | description: Comma-separated list of advisory ids to ignore 13 | required: false 14 | working-directory: 15 | description: The directory of the Cargo.toml / Cargo.lock files to scan. 16 | required: false 17 | default: . 18 | 19 | runs: 20 | using: 'node20' 21 | main: 'dist/index.js' 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | "\\.(ts|js)x?$": "ts-jest" 9 | }, 10 | verbose: true 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rust-audit-check", 3 | "version": "2.0.0", 4 | "private": false, 5 | "description": "Security audit for security vulnerabilities", 6 | "main": "lib/main.js", 7 | "directories": { 8 | "lib": "lib", 9 | "test": "__tests__" 10 | }, 11 | "scripts": { 12 | "build": "ncc build src/main.ts --minify", 13 | "watch": "ncc build src/main.ts --watch --minify", 14 | "test": "jest --passWithNoTests", 15 | "format": "prettier --write 'src/**/*.{js,ts,tsx}'", 16 | "refresh": "rm -rf ./dist/* && npm run-script build", 17 | "lint": "tsc --noEmit && eslint 'src/**/*.{js,ts,tsx}'" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/actions-rs/audit.git" 22 | }, 23 | "keywords": [ 24 | "actions", 25 | "rust", 26 | "cargo", 27 | "audit", 28 | "security", 29 | "advisory" 30 | ], 31 | "author": "actions-rs", 32 | "license": "MIT", 33 | "bugs": { 34 | "url": "https://github.com/actions-rs/audit-check/issues" 35 | }, 36 | "dependencies": { 37 | "@clechasseur/rs-actions-core": "^3.0.5", 38 | "nunjucks": "^3.2.4" 39 | }, 40 | "devDependencies": { 41 | "@typescript-eslint/parser": "^6.21.0", 42 | "@typescript-eslint/eslint-plugin": "^6.21.0", 43 | "ts-node": "^10.9.2", 44 | "eslint": "^8.56.0", 45 | "eslint-config-prettier": "^9.1.0", 46 | "eslint-plugin-prettier": "^5.1.3", 47 | "@types/jest": "^29.5.12", 48 | "@types/node": "^20.11.17", 49 | "@vercel/ncc": "0.38.1", 50 | "jest": "^29.7.0", 51 | "ts-jest": "^29.1.2", 52 | "typescript": "^5.3.3", 53 | "prettier": "^3.2.5" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/input.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Parse action input into a some proper thing. 3 | */ 4 | 5 | import { input } from '@clechasseur/rs-actions-core'; 6 | 7 | // Parsed action input 8 | export interface Input { 9 | token: string; 10 | ignore: string[]; 11 | workingDirectory: string; 12 | } 13 | 14 | export function get(): Input { 15 | return { 16 | token: input.getInput('token', { required: true }), 17 | ignore: input.getInputList('ignore', { required: false }), 18 | workingDirectory: input.getInput('working-directory', { required: false }) ?? '.', 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/interfaces.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * These types should match to what `cargo-audit` outputs in a JSON format. 3 | * 4 | * See `rustsec` crate for structs used for serialization. 5 | */ 6 | 7 | export interface Report { 8 | database: DatabaseInfo; 9 | lockfile: LockfileInfo; 10 | vulnerabilities: VulnerabilitiesInfo; 11 | warnings: Warning[] | { [key: string]: Warning[] }; 12 | } 13 | 14 | export interface DatabaseInfo { 15 | 'advisory-count': number; 16 | 'last-commit': string; 17 | 'last-updated': string; 18 | } 19 | 20 | export interface LockfileInfo { 21 | 'dependency-count': number; 22 | } 23 | 24 | export interface VulnerabilitiesInfo { 25 | found: boolean; 26 | count: number; 27 | list: Vulnerability[]; 28 | } 29 | 30 | export interface Vulnerability { 31 | advisory: Advisory; 32 | package: Package; 33 | } 34 | 35 | export interface Advisory { 36 | id: string; 37 | package: string; 38 | title: string; 39 | description: string; 40 | informational: undefined | string | 'notice' | 'unmaintained'; 41 | url: string; 42 | } 43 | 44 | export interface Package { 45 | name: string; 46 | version: string; 47 | } 48 | 49 | export interface Warning { 50 | kind: 'unmaintained' | 'informational' | 'yanked' | string; 51 | advisory: Advisory; 52 | package: Package; 53 | } 54 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'process'; 2 | import * as os from 'os'; 3 | 4 | import * as core from '@actions/core'; 5 | import * as github from '@actions/github'; 6 | 7 | import { Cargo } from '@clechasseur/rs-actions-core'; 8 | 9 | import * as input from './input'; 10 | import * as interfaces from './interfaces'; 11 | import * as reporter from './reporter'; 12 | 13 | async function getData( 14 | ignore: string[] | undefined, 15 | workingDirectory: string, 16 | ): Promise { 17 | const cargo = await Cargo.get(); 18 | await cargo.findOrInstall('cargo-audit'); 19 | 20 | let stdout = ''; 21 | try { 22 | core.startGroup('Calling cargo-audit (JSON output)'); 23 | const commandArray = ['audit']; 24 | for (const item of ignore ?? []) { 25 | commandArray.push('--ignore', item); 26 | } 27 | commandArray.push('--json'); 28 | commandArray.push('--file', `${workingDirectory}/Cargo.lock`); 29 | await cargo.call(commandArray, { 30 | ignoreReturnCode: true, 31 | listeners: { 32 | stdout: (buffer) => { 33 | stdout += buffer.toString(); 34 | }, 35 | }, 36 | }); 37 | } finally { 38 | // Cool story: `cargo-audit` JSON output is missing the trailing `\n`, 39 | // so the `::endgroup::` annotation from the line below is being 40 | // eaten by it. 41 | // Manually writing the `\n` to denote the `cargo-audit` end 42 | process.stdout.write(os.EOL); 43 | core.endGroup(); 44 | } 45 | 46 | return JSON.parse(stdout); 47 | } 48 | 49 | function removeTrailingSlash(str) { 50 | if (str[str.length - 1] === '/') { 51 | return str.substr(0, str.length - 1); 52 | } 53 | return str; 54 | } 55 | 56 | export async function run(actionInput: input.Input): Promise { 57 | const ignore = actionInput.ignore; 58 | const workingDirectory = removeTrailingSlash(actionInput.workingDirectory); 59 | const report = await getData(ignore, workingDirectory); 60 | let shouldReport = false; 61 | if (!report.vulnerabilities.found) { 62 | core.info('No vulnerabilities were found'); 63 | } else { 64 | core.warning(`${report.vulnerabilities.count} vulnerabilities found!`); 65 | shouldReport = true; 66 | } 67 | 68 | // In `cargo-audit < 0.12` report contained an array of `Warning`. 69 | // In `cargo-audit >= 0.12` it is a JSON object, 70 | // where key is a warning type, and value is an array of `Warning` of that type. 71 | let warnings: Array = []; 72 | if (Array.isArray(report.warnings)) { 73 | warnings = report.warnings; 74 | } else { 75 | for (const items of Object.values(report.warnings)) { 76 | warnings = warnings.concat(items); 77 | } 78 | } 79 | 80 | if (warnings.length === 0) { 81 | core.info('No warnings were found'); 82 | } else { 83 | core.warning(`${warnings.length} warnings found!`); 84 | shouldReport = true; 85 | } 86 | 87 | if (!shouldReport) { 88 | return; 89 | } 90 | 91 | // const octokit = github.getOctokit(actionInput.token, {userAgent: USER_AGENT}); 92 | const advisories = report.vulnerabilities.list; 93 | if (github.context.eventName == 'schedule') { 94 | core.debug( 95 | 'Action was triggered on a schedule event, creating an Issues report', 96 | ); 97 | await reporter.reportIssues(actionInput.token, advisories, warnings); 98 | } else { 99 | core.debug( 100 | `Action was triggered on a ${github.context.eventName} event, creating a Check report`, 101 | ); 102 | await reporter.reportCheck(actionInput.token, advisories, warnings); 103 | } 104 | } 105 | 106 | async function main(): Promise { 107 | try { 108 | const actionInput = input.get(); 109 | await run(actionInput); 110 | } catch (error) { 111 | core.setFailed((error as Error).message); 112 | } 113 | 114 | return; 115 | } 116 | 117 | main(); 118 | -------------------------------------------------------------------------------- /src/reporter.ts: -------------------------------------------------------------------------------- 1 | import * as process from 'process'; 2 | 3 | import * as core from '@actions/core'; 4 | import * as github from '@actions/github'; 5 | import * as nunjucks from 'nunjucks'; 6 | 7 | import { checks } from '@clechasseur/rs-actions-core'; 8 | import * as interfaces from './interfaces'; 9 | import * as templates from './templates'; 10 | 11 | const pkg = require('../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires 12 | const USER_AGENT = `${pkg.name}/${pkg.version} (${pkg.bugs.url})`; 13 | 14 | interface Stats { 15 | critical: number; 16 | notices: number; 17 | unmaintained: number; 18 | unsound: number; 19 | other: number; 20 | } 21 | 22 | nunjucks.configure({ 23 | trimBlocks: true, 24 | lstripBlocks: true, 25 | }); 26 | 27 | function makeReport( 28 | vulnerabilities: Array, 29 | warnings: Array, 30 | ): string { 31 | const preparedWarnings: Array = []; 32 | for (const warning of warnings) { 33 | switch (warning.kind) { 34 | case 'unmaintained': 35 | preparedWarnings.push({ 36 | advisory: warning.advisory, 37 | package: warning.package, 38 | }); 39 | break; 40 | 41 | case 'unsound': 42 | preparedWarnings.push({ 43 | advisory: warning.advisory, 44 | package: warning.package, 45 | }); 46 | break; 47 | 48 | case 'notice': 49 | preparedWarnings.push({ 50 | advisory: warning.advisory, 51 | package: warning.package, 52 | }); 53 | break; 54 | 55 | case 'informational': 56 | preparedWarnings.push({ 57 | advisory: warning.advisory, 58 | package: warning.package, 59 | }); 60 | break; 61 | 62 | case 'yanked': 63 | preparedWarnings.push({ 64 | package: warning.package, 65 | }); 66 | break; 67 | 68 | default: 69 | core.warning( 70 | `Unknown warning kind ${warning.kind} found, please, file a bug`, 71 | ); 72 | break; 73 | } 74 | } 75 | 76 | return nunjucks.renderString(templates.REPORT, { 77 | vulnerabilities: vulnerabilities, 78 | warnings: preparedWarnings, 79 | }); 80 | } 81 | 82 | export function plural(value: number, suffix = 's'): string { 83 | return value == 1 ? '' : suffix; 84 | } 85 | 86 | function getStats( 87 | vulnerabilities: Array, 88 | warnings: Array, 89 | ): Stats { 90 | let critical = 0; 91 | let notices = 0; 92 | let unmaintained = 0; 93 | let unsound = 0; 94 | let other = 0; 95 | for (const vulnerability of vulnerabilities) { 96 | switch (vulnerability.advisory.informational) { 97 | case 'notice': 98 | notices += 1; 99 | break; 100 | case 'unmaintained': 101 | unmaintained += 1; 102 | break; 103 | case 'unsound': 104 | unsound += 1; 105 | break; 106 | case null: 107 | critical += 1; 108 | break; 109 | default: 110 | other += 1; 111 | break; 112 | } 113 | } 114 | 115 | for (const warning of warnings) { 116 | switch (warning.kind) { 117 | case 'unmaintained': 118 | unmaintained += 1; 119 | break; 120 | 121 | case 'unsound': 122 | unsound += 1; 123 | break; 124 | 125 | default: 126 | // Both yanked and informational types of kind 127 | other += 1; 128 | break; 129 | } 130 | } 131 | 132 | return { 133 | critical: critical, 134 | notices: notices, 135 | unmaintained: unmaintained, 136 | unsound: unsound, 137 | other: other, 138 | }; 139 | } 140 | 141 | function getSummary(stats: Stats): string { 142 | const blocks: string[] = []; 143 | 144 | if (stats.critical > 0) { 145 | blocks.push(`${stats.critical} advisories`); 146 | } 147 | if (stats.notices > 0) { 148 | blocks.push(`${stats.notices} notice${plural(stats.notices)}`); 149 | } 150 | if (stats.unmaintained > 0) { 151 | blocks.push(`${stats.unmaintained} unmaintained`); 152 | } 153 | if (stats.unsound > 0) { 154 | blocks.push(`${stats.unsound} unsound`); 155 | } 156 | if (stats.other > 0) { 157 | blocks.push(`${stats.other} other`); 158 | } 159 | 160 | return blocks.join(', '); 161 | } 162 | 163 | /// Create and publish audit results into the Commit Check. 164 | export async function reportCheck( 165 | token: string, 166 | vulnerabilities: Array, 167 | warnings: Array, 168 | ): Promise { 169 | const client = github.getOctokit(token, {userAgent: USER_AGENT}); 170 | const reporter = new checks.CheckReporter(client.rest, 'Security audit'); 171 | const stats = getStats(vulnerabilities, warnings); 172 | const summary = getSummary(stats); 173 | 174 | core.info(`Found ${summary}`); 175 | 176 | try { 177 | await reporter.startCheck('queued'); 178 | } catch (error) { 179 | // `GITHUB_HEAD_REF` is set only for forked repos, 180 | // so we can check if it is a fork and not a base repo. 181 | if (process.env.GITHUB_HEAD_REF) { 182 | core.error(`Unable to publish audit check! Reason: ${error}`); 183 | core.warning( 184 | 'It seems that this Action is executed from the forked repository.', 185 | ); 186 | core.warning(`GitHub Actions are not allowed to use Check API, \ 187 | when executed for a forked repos. \ 188 | See https://github.com/actions-rs/clippy-check/issues/2 for details.`); 189 | core.info('Posting audit report here instead.'); 190 | 191 | core.info(makeReport(vulnerabilities, warnings)); 192 | if (stats.critical > 0) { 193 | throw new Error( 194 | 'Critical vulnerabilities were found, marking check as failed', 195 | ); 196 | } else { 197 | core.info( 198 | 'No critical vulnerabilities were found, not marking check as failed', 199 | ); 200 | return; 201 | } 202 | } 203 | 204 | throw error; 205 | } 206 | 207 | try { 208 | const body = makeReport(vulnerabilities, warnings); 209 | const output = { 210 | title: 'Security advisories found', 211 | summary: summary, 212 | text: body, 213 | }; 214 | const status = stats.critical > 0 ? 'failure' : 'success'; 215 | await reporter.finishCheck(status, output); 216 | } catch (error) { 217 | await reporter.cancelCheck(); 218 | throw error; 219 | } 220 | 221 | if (stats.critical > 0) { 222 | throw new Error( 223 | 'Critical vulnerabilities were found, marking check as failed', 224 | ); 225 | } else { 226 | core.info( 227 | 'No critical vulnerabilities were found, not marking check as failed', 228 | ); 229 | return; 230 | } 231 | } 232 | 233 | async function alreadyReported( 234 | token: string, 235 | advisoryId: string, 236 | ): Promise { 237 | const { owner, repo } = github.context.repo; 238 | const client = github.getOctokit(token, {userAgent: USER_AGENT}); 239 | const results = await client.rest.search.issuesAndPullRequests({ 240 | q: `${advisoryId} in:title repo:${owner}/${repo}`, 241 | per_page: 1, // eslint-disable-line @typescript-eslint/camelcase 242 | }); 243 | 244 | if (results.data.total_count > 0) { 245 | core.info( 246 | `Seems like ${advisoryId} is mentioned already in the issues/PRs, \ 247 | will not report an issue against it`, 248 | ); 249 | return true; 250 | } else { 251 | return false; 252 | } 253 | } 254 | 255 | export async function reportIssues( 256 | token: string, 257 | vulnerabilities: Array, 258 | warnings: Array, 259 | ): Promise { 260 | const { owner, repo } = github.context.repo; 261 | 262 | const client = github.getOctokit(token, {userAgent: USER_AGENT}); 263 | 264 | for (const vulnerability of vulnerabilities) { 265 | const reported = await alreadyReported( 266 | token, 267 | vulnerability.advisory.id, 268 | ); 269 | if (reported) { 270 | continue; 271 | } 272 | 273 | const body = nunjucks.renderString(templates.VULNERABILITY_ISSUE, { 274 | vulnerability: vulnerability, 275 | }); 276 | const issue = await client.rest.issues.create({ 277 | owner: owner, 278 | repo: repo, 279 | title: `${vulnerability.advisory.id}: ${vulnerability.advisory.title}`, 280 | body: body, 281 | }); 282 | core.info( 283 | `Created an issue for ${vulnerability.advisory.id}: ${issue.data.html_url}`, 284 | ); 285 | } 286 | 287 | for (const warning of warnings) { 288 | let advisory: interfaces.Advisory; 289 | switch (warning.kind) { 290 | case 'unsound': 291 | case 'notice': 292 | case 'unmaintained': 293 | case 'informational': 294 | advisory = warning.advisory; 295 | break; 296 | case 'yanked': 297 | core.warning( 298 | `Crate ${warning.package.name} was yanked, but no issue will be reported about it`, 299 | ); 300 | continue; 301 | default: 302 | core.warning( 303 | `Unknown warning kind ${warning.kind} found, please, file a bug`, 304 | ); 305 | continue; 306 | } 307 | 308 | const reported = await alreadyReported(token, advisory.id); 309 | if (reported) { 310 | continue; 311 | } 312 | 313 | const body = nunjucks.renderString(templates.WARNING_ISSUE, { 314 | warning: warning, 315 | advisory: advisory, 316 | }); 317 | const issue = await client.rest.issues.create({ 318 | owner: owner, 319 | repo: repo, 320 | title: `${advisory.id}: ${advisory.title}`, 321 | body: body, 322 | }); 323 | core.info( 324 | `Created an issue for ${advisory.id}: ${issue.data.html_url}`, 325 | ); 326 | } 327 | } 328 | -------------------------------------------------------------------------------- /src/templates.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Naive way to bundle the templates in order to skip any build/watch pre-processing steps. 3 | */ 4 | 5 | import * as interfaces from './interfaces'; 6 | 7 | export interface ReportWarning { 8 | advisory?: interfaces.Advisory; 9 | package: interfaces.Package; 10 | } 11 | 12 | export const REPORT = ` 13 | {% if vulnerabilities.length > 0 %} 14 | ## Vulnerabilities 15 | 16 | {% for v in vulnerabilities %} 17 | ### [{{ v.advisory.id }}](https://rustsec.org/advisories/{{ v.advisory.id }}.html) 18 | 19 | > {{ v.advisory.title }} 20 | 21 | | Details | | 22 | | ------------------- | ---------------------------------------------- | 23 | {% if v.advisory.informational %} 24 | | Status | {{ v.advisory.informational }} | 25 | {% endif %} 26 | | Package | \`{{ v.package.name }}\` | 27 | | Version | \`{{ v.package.version }}\` | 28 | {% if v.advisory.url %} 29 | | URL | [{{ v.advisory.url }}]({{ v.advisory.url }}) | 30 | {% endif %} 31 | | Date | {{ v.advisory.date }} | 32 | {% if v.versions.patched.length > 0 %} 33 | | Patched versions | \`{{ v.versions.patched | safe }}\` | 34 | {% endif %} 35 | {% if v.versions.unaffected.length > 0 %} 36 | | Unaffected versions | \`{{ v.versions.unaffected | safe }}\` | 37 | {% endif %} 38 | 39 | {{ v.advisory.description }} 40 | {% endfor %} 41 | {% endif %} 42 | 43 | {% if warnings.length > 0 %} 44 | ## Warnings 45 | 46 | {% for w in warnings %} 47 | {% if w.advisory %} 48 | ### [{{ w.advisory.id }}](https://rustsec.org/advisories/{{ w.advisory.id }}.html) 49 | 50 | > {{ w.advisory.title }} 51 | 52 | | Details | | 53 | | ------------------- | ---------------------------------------------- | 54 | {% if w.advisory.informational %} 55 | | Status | {{ w.advisory.informational }} | 56 | {% endif %} 57 | | Package | \`{{ w.package.name }}\` | 58 | | Version | \`{{ w.package.version | safe }}\` | 59 | {% if w.advisory.url %} 60 | | URL | [{{ w.advisory.url }}]({{ w.advisory.url }}) | 61 | {% endif %} 62 | | Date | {{ w.advisory.date }} | 63 | {% if w.versions.patched.length > 0 %} 64 | | Patched versions | \`{{ w.versions.patched | safe }}\` | 65 | {% endif %} 66 | {% if w.versions.unaffected.length > 0 %} 67 | | Unaffected versions | \`{{ w.versions.unaffected | safe }}\` | 68 | {% endif %} 69 | 70 | {{ w.advisory.description }} 71 | {% else %} 72 | ### Crate \`{{ w.package.name }}\` is yanked 73 | 74 | No extra details provided. 75 | 76 | {% endif %} 77 | {% endfor %} 78 | {% endif %} 79 | `; 80 | 81 | export const VULNERABILITY_ISSUE = ` 82 | > {{ vulnerability.advisory.title }} 83 | 84 | | Details | | 85 | | ------------------- | ---------------------------------------------- | 86 | {% if vulnerability.advisory.informational %} 87 | | Status | {{ vulnerability.advisory.informational }} | 88 | {% endif %} 89 | | Package | \`{{ vulnerability.package.name }}\` | 90 | | Version | \`{{ vulnerability.package.version | safe }}\` | 91 | {% if vulnerability.advisory.url %} 92 | | URL | [{{ vulnerability.advisory.url }}]({{ vulnerability.advisory.url }}) | 93 | {% endif %} 94 | | Date | {{ vulnerability.advisory.date }} | 95 | {% if vulnerability.versions.patched.length > 0 %} 96 | | Patched versions | \`{{ vulnerability.versions.patched | safe }}\` | 97 | {% endif %} 98 | {% if vulnerability.versions.unaffected.length > 0 %} 99 | | Unaffected versions | \`{{ vulnerability.versions.unaffected | safe }}\` | 100 | {% endif %} 101 | 102 | {{ vulnerability.advisory.description }} 103 | 104 | See [advisory page](https://rustsec.org/advisories/{{ vulnerability.advisory.id }}.html) for additional details. 105 | `; 106 | 107 | export const WARNING_ISSUE = ` 108 | > {{ advisory.title }} 109 | 110 | | Details | | 111 | | ------------------- | ---------------------------------------------- | 112 | {% if advisory.informational %} 113 | | Status | {{ advisory.informational }} | 114 | {% endif %} 115 | | Package | \`{{ warning.package.name }}\` | 116 | | Version | \`{{ warning.package.version | safe }}\` | 117 | | URL | [{{ advisory.url }}]({{ advisory.url }}) | 118 | | Date | {{ advisory.date }} | 119 | 120 | {{ advisory.description }} 121 | 122 | See [advisory page](https://rustsec.org/advisories/{{ advisory.id }}.html) for additional details. 123 | `; 124 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": true, 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "newLine": "LF", 10 | "noEmitOnError": true, 11 | "noErrorTruncation": true, 12 | "noFallthroughCasesInSwitch": true, 13 | // TODO: enabling it breaks the `@actions/github` package somehow 14 | "noImplicitAny": false, 15 | "noImplicitReturns": true, 16 | "noImplicitThis": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "outDir": "dist", 20 | "pretty": true, 21 | "removeComments": true, 22 | "resolveJsonModule": true, 23 | "rootDir": "src", 24 | "strict": true, 25 | "suppressImplicitAnyIndexErrors": false, 26 | "target": "es2018" 27 | }, 28 | "include": [ 29 | "src" 30 | ] 31 | } 32 | --------------------------------------------------------------------------------