├── .github ├── dependabot.yml └── workflows │ └── autotag-releases.yml ├── .pre-commit-config.yaml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── action.yml ├── audit.py └── imgs └── audit-summary.png /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.github/workflows/autotag-releases.yml: -------------------------------------------------------------------------------- 1 | # Create tags with only major or major.minor version 2 | # This runs for every created release. Releases are expected to use vmajor.minor.patch as tags. 3 | 4 | name: Autotag Release 5 | on: 6 | release: 7 | types: [released] 8 | workflow_dispatch: 9 | permissions: read-all 10 | 11 | jobs: 12 | autotag_release: 13 | runs-on: ubuntu-latest 14 | if: startsWith(github.ref, 'refs/tags/v') 15 | permissions: 16 | contents: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Get version from tag 20 | id: tag_name 21 | run: | 22 | echo "current_version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 23 | shell: bash 24 | - name: Create and push tags 25 | run: | 26 | MINOR="$(echo -n ${{ steps.tag_name.outputs.current_version }} | cut -d. -f1-2)" 27 | MAJOR="$(echo -n ${{ steps.tag_name.outputs.current_version }} | cut -d. -f1)" 28 | git tag -f "${MINOR}" 29 | git tag -f "${MAJOR}" 30 | git push -f origin "${MINOR}" 31 | git push -f origin "${MAJOR}" 32 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/psf/black 3 | rev: 25.1.0 4 | hooks: 5 | - id: black 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v5.0.0 8 | hooks: 9 | - id: check-ast 10 | - id: check-case-conflict 11 | - id: check-executables-have-shebangs 12 | - id: check-merge-conflict 13 | - id: check-yaml 14 | - id: end-of-file-fixer 15 | - id: trailing-whitespace 16 | - repo: https://github.com/PyCQA/isort 17 | rev: 6.0.1 18 | # https://github.com/psf/black/blob/main/docs/guides/using_black_with_other_tools.md 19 | hooks: 20 | - id: isort 21 | args: ["--profile=black"] 22 | - repo: https://github.com/asottile/pyupgrade 23 | rev: v3.20.0 24 | hooks: 25 | - id: pyupgrade 26 | args: ["--py37-plus"] 27 | - repo: https://github.com/pre-commit/mirrors-mypy 28 | rev: v1.16.0 29 | hooks: 30 | - id: mypy 31 | additional_dependencies: 32 | - types-requests 33 | - repo: https://github.com/python-jsonschema/check-jsonschema 34 | rev: 0.33.0 35 | hooks: 36 | - id: check-dependabot 37 | - id: check-github-actions 38 | - id: check-github-workflows 39 | -------------------------------------------------------------------------------- /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 | ## [Unreleased] 9 | 10 | ## [1.2.4] - 2025-03-03 11 | 12 | * Update `cargo-audit` to 0.21.2 13 | 14 | ## [1.2.3] - 2024-12-17 15 | 16 | * Show a better error message when running "cargo audit" fails #98 17 | 18 | ## [1.2.2] - 2024-11-06 19 | 20 | * Update `cargo-audit` to 0.21.0 21 | 22 | ## [1.2.1] - 2024-07-31 23 | 24 | * Temporarily remove `--locked` from the install instructions again, since cargo-audit relies on an old version of `time` that is incompatible with Rust 1.80. 25 | 26 | ## [1.2.0] - 2024-03-05 27 | 28 | * feat: add --locked to cargo install cargo-audit by @lwshang in #72 29 | * Add working directory input to configure where cargo audit executes by @jonasbb in #78 30 | 31 | ## [1.1.14] - 2024-02-18 32 | 33 | * Update `cargo-audit` to 0.20.0 34 | 35 | ## [1.1.13] - 2024-02-03 36 | 37 | * Update `cargo-audit` to 0.19.0 38 | 39 | ## [1.1.12] - 2024-01-20 40 | 41 | * Fix default of `file` argument to make it work again for repositories without `Cargo.lock` checked in. 42 | 43 | ## [1.1.11] - 2024-01-18 44 | 45 | * Allow specifying the path to the `Cargo.lock` file, in case it is not in the root of the repository (#55) 46 | * Update the example in the README, to have the correct permissions for private repositories. 47 | 48 | ## [1.1.10] - 2023-11-02 49 | 50 | * Fix running the action, by using the correct version of the cache action. 51 | 52 | ## [1.1.9] - 2023-11-01 53 | 54 | * Update `cargo-audit` to 0.18.3 55 | 56 | ## [1.1.8] - 2023-08-23 57 | 58 | * Handle missing data in advisories better to prevent crashing (#40) 59 | 60 | ## [1.1.7] - 2023-05-12 61 | 62 | * Update `cargo-audit` to 0.17.6 63 | 64 | ## [1.1.6] - 2023-03-24 65 | 66 | * Update `cargo-audit` to 0.17.5 67 | 68 | ## [1.1.5] - 2022-12-22 69 | 70 | * Fix duplicate issues for yanked crates. 71 | 72 | The previous version introduced a bug where existing issues were not properly detected. 73 | This only affected issues for yanked crates. 74 | Now duplicate issues will no longer be created. 75 | 76 | ## [1.1.4] - 2022-12-22 77 | 78 | * Handle warnings without any associated advisory. 79 | 80 | This occurs for yanked crates, where the `advisory` field is `null` in the JSON output. 81 | Now a message is shown that the crate and version is yanked. 82 | 83 | ## [1.1.3] - 2022-12-05 84 | 85 | * Fix the path to the cargo installation directory to fix caching. 86 | 87 | ## [1.1.2] - 2022-11-09 88 | 89 | ### Changed 90 | 91 | * Update `cargo-audit` to 0.17.4 which fixes checking for yanked crates. 92 | 93 | ## [1.1.1] - 2022-10-13 94 | 95 | ### Changed 96 | 97 | * Switch from set-output to $GITHUB_OUTPUT to avoid warning 98 | https://github.blog/changelog/2022-10-11-github-actions-deprecating-save-state-and-set-output-commands/ 99 | 100 | ## [1.1.0] - 2022-08-14 101 | 102 | ### Added 103 | 104 | * Present aliases for the RustSec ID and related advisories in the overview table (#1). 105 | 106 | ### Changed 107 | 108 | * Setting `denyWarnings` will now pass `--deny warnings` to cargo audit. 109 | 110 | ## [1.0.1] - 2022-08-09 111 | 112 | ### Added 113 | 114 | * Create proper release tags. 115 | 116 | ## [1.0.0] - 2022-08-09 117 | 118 | Initial Version 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Rust Actions 4 | Copyright (c) 2020 Alastair Mooney 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 all 14 | 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 THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Audit Rust dependencies using the RustSec Advisory DB 2 | 3 | Audit your Rust dependencies using [cargo audit] and the [RustSec Advisory DB]. The action creates a summary with all vulnerabilities. It can create issues for each of the found vulnerabilities. 4 | 5 | Execution Summary: 6 | 7 | ![The action reports any audit results.](./imgs/audit-summary.png) 8 | 9 | ## Example workflow 10 | 11 | ```yaml 12 | name: "Audit Dependencies" 13 | on: 14 | push: 15 | paths: 16 | # Run if workflow changes 17 | - '.github/workflows/audit.yml' 18 | # Run on changed dependencies 19 | - '**/Cargo.toml' 20 | - '**/Cargo.lock' 21 | # Run if the configuration file changes 22 | - '**/audit.toml' 23 | # Rerun periodically to pick up new advisories 24 | schedule: 25 | - cron: '0 0 * * *' 26 | # Run manually 27 | workflow_dispatch: 28 | 29 | jobs: 30 | audit: 31 | runs-on: ubuntu-latest 32 | permissions: 33 | contents: read 34 | issues: write 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: actions-rust-lang/audit@v1 38 | name: Audit Rust Dependencies 39 | with: 40 | # Comma separated list of issues to ignore 41 | ignore: RUSTSEC-2020-0036 42 | ``` 43 | 44 | ## Inputs 45 | 46 | All inputs are optional. 47 | Consider adding an [`audit.toml` configuration file] to your repository for further configurations. 48 | cargo audit supports multiple warning types, such as unsound code or yanked crates. 49 | Configuration is only possible via the `informational_warnings` parameter in the configuration file ([#318](https://github.com/rustsec/rustsec/issues/318)). 50 | Setting `denyWarnings` to true will also enable these warnings, but each warning is upgraded to an error. 51 | 52 | | Name | Description | Default | 53 | | ------------------ | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | 54 | | `TOKEN` | The GitHub access token to allow us to retrieve, create and update issues (automatically set). | `github.token` | 55 | | `denyWarnings` | Any warnings generated will be treated as an error and fail the action. | false | 56 | | `file` | The path to the Cargo.lock file to inspect file. | | 57 | | `ignore` | A comma separated list of Rustsec IDs to ignore. | | 58 | | `createIssues` | Create/Update issues for each found vulnerability. By default only on `main` or `master` branch. | `github.ref == 'refs/heads/master' \|\| github.ref == 'refs/heads/main'` | 59 | | `workingDirectory` | Run `cargo audit` from the given working directory | | 60 | 61 | ## Dependencies 62 | 63 | The action works best on the GitHub-hosted runners, but can work on self-hosted ones too, provided the necessary dependencies are available. 64 | PRs to add support for more environments are welcome. 65 | 66 | * bash 67 | * Python 3.9+ 68 | * requests 69 | * Rust stable 70 | * cargo 71 | * use node actions 72 | 73 | ## License 74 | 75 | The scripts and documentation in this project are released under the [MIT License]. 76 | 77 | [MIT License]: LICENSE 78 | [cargo audit]: https://github.com/RustSec/rustsec/tree/main/cargo-audit 79 | [RustSec Advisory DB]: https://rustsec.org/advisories/ 80 | [`audit.toml` configuration file]: https://github.com/rustsec/rustsec/blob/main/cargo-audit/audit.toml.example 81 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: cargo audit your Rust Dependencies 2 | description: | 3 | Audit Rust dependencies with cargo audit and the RustSec Advisory DB 4 | branding: 5 | icon: "shield" 6 | color: "red" 7 | 8 | inputs: 9 | TOKEN: 10 | description: "The GitHub access token to allow us to retrieve, create and update issues (automatically set)" 11 | required: false 12 | default: ${{ github.token }} 13 | denyWarnings: 14 | description: "Any warnings generated will be treated as an error and fail the action" 15 | required: false 16 | default: "false" 17 | file: 18 | description: "The path to the Cargo.lock file to inspect" 19 | required: false 20 | default: "" 21 | ignore: 22 | description: "A comma separated list of Rustsec IDs to ignore" 23 | required: false 24 | default: "" 25 | createIssues: 26 | description: Create/Update issues for each found vulnerability. 27 | required: false 28 | default: "${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}" 29 | workingDirectory: 30 | description: "Run `cargo audit` from the given working directory" 31 | required: false 32 | default: "" 33 | 34 | runs: 35 | using: composite 36 | steps: 37 | - name: Identify cargo installation directory 38 | run: echo "cargohome=${CARGO_HOME:-$HOME/.cargo}" >> $GITHUB_OUTPUT 39 | shell: bash 40 | id: cargo-home 41 | - uses: actions/cache@v4 42 | id: cache 43 | with: 44 | path: | 45 | ${{ steps.cargo-home.outputs.cargohome }}/bin/cargo-audit* 46 | ${{ steps.cargo-home.outputs.cargohome }}/.crates.toml 47 | ${{ steps.cargo-home.outputs.cargohome }}/.crates2.json 48 | key: cargo-audit-v0.21.2 49 | 50 | - name: Install cargo-audit 51 | if: steps.cache.outputs.cache-hit != 'true' 52 | # Update both this version number and the cache key 53 | run: cargo install cargo-audit --vers 0.21.2 --no-default-features 54 | shell: bash 55 | 56 | - run: | 57 | import audit 58 | audit.run() 59 | shell: python 60 | env: 61 | INPUT_CREATE_ISSUES: ${{ inputs.createIssues }} 62 | INPUT_DENY_WARNINGS: ${{ inputs.denyWarnings }} 63 | INPUT_FILE: ${{ inputs.file }} 64 | INPUT_IGNORE: ${{ inputs.ignore }} 65 | INPUT_TOKEN: ${{ inputs.TOKEN }} 66 | INPUT_WORKING_DIRECTORY: ${{ inputs.workingDirectory }} 67 | PYTHONPATH: ${{ github.action_path }} 68 | REPO: ${{ github.repository }} 69 | -------------------------------------------------------------------------------- /audit.py: -------------------------------------------------------------------------------- 1 | import enum 2 | import json 3 | import os 4 | import subprocess 5 | import sys 6 | from typing import Any, Dict, List, Optional, Union 7 | 8 | import requests 9 | 10 | # GitHub API Client copied and adapted from 11 | # https://github.com/alstr/todo-to-issue-action/blob/25c80e9c4999d107bec208af49974d329da26370/main.py 12 | # Originally licensed under MIT license 13 | 14 | TIMEOUT = 30 15 | """Timeout in seconds for requests methods""" 16 | 17 | NEWLINE = "\n" 18 | """Definition of newline""" 19 | 20 | 21 | def debug(message: str) -> None: 22 | """Print a debug message to the GitHub Action log""" 23 | print(f"""::debug::{message.replace(NEWLINE, " ")}""") 24 | 25 | 26 | def error(message: str) -> None: 27 | """Print an error message to the GitHub Action log""" 28 | print(f"""::error::{message.replace(NEWLINE, " ")}""") 29 | 30 | 31 | def group(title: str, message: str) -> None: 32 | """Print an expandable group message to the GitHub Action log""" 33 | print(f"::group::{title}") 34 | print(message) 35 | print("::endgroup::") 36 | 37 | 38 | class Issue: 39 | """Basic Issue model for collecting the necessary info to send to GitHub.""" 40 | 41 | def __init__( 42 | self, 43 | title: str, 44 | labels: List[str], 45 | assignees: List[str], 46 | body: str, 47 | rustsec_id: str, # Should be the start of the title 48 | ) -> None: 49 | self.title = title 50 | self.labels = labels 51 | self.assignees = assignees 52 | self.body = body 53 | 54 | self.rustsec_id = rustsec_id 55 | 56 | 57 | class EntryType(enum.Enum): 58 | ERROR = "error" 59 | WARNING = "warning" 60 | 61 | def icon(self) -> str: 62 | if self == EntryType.ERROR: 63 | return "🛑" 64 | elif self == EntryType.WARNING: 65 | return "⚠️" 66 | else: 67 | return "" 68 | 69 | 70 | class Entry: 71 | entry: Dict[str, Any] 72 | entry_type: EntryType 73 | warning_type: Optional[str] = None 74 | 75 | def __init__( 76 | self, 77 | entry: Dict[str, Any], 78 | entry_type: EntryType, 79 | warning_type: Optional[str] = None, 80 | ): 81 | self.entry = entry 82 | self.entry_type = entry_type 83 | self.warning_type = warning_type 84 | 85 | def id(self) -> str: 86 | """ 87 | Return the ID of the entry. 88 | """ 89 | 90 | # IMPORTANT: Coordinate this value with the `_get_existing_issues` method below. 91 | # Any value returned here must also be present in the filtering there, since the id will be used in the issue title. 92 | 93 | advisory = self.entry.get("advisory", None) 94 | if advisory: 95 | return advisory["id"] 96 | else: 97 | return f"Crate {self.entry['package']['name']} {self.entry['package']['version']}" 98 | 99 | def _entry_table(self) -> str: 100 | advisory = self.entry.get("advisory", None) 101 | 102 | if advisory: 103 | table = [] 104 | table.append(("Details", "")) 105 | table.append(("---", "---")) 106 | table.append(("Package", f"`{advisory['package']}`")) 107 | table.append(("Version", f"`{self.entry['package']['version']}`")) 108 | if self.warning_type is not None: 109 | table.append(("Warning", str(self.warning_type))) 110 | table.append(("URL", advisory["url"])) 111 | table.append( 112 | ( 113 | "Patched Versions", 114 | ( 115 | " OR ".join(self.entry["versions"]["patched"]) 116 | if len(self.entry["versions"]["patched"]) > 0 117 | else "n/a" 118 | ), 119 | ) 120 | ) 121 | if len(self.entry["versions"]["unaffected"]) > 0: 122 | table.append( 123 | ( 124 | "Unaffected Versions", 125 | " OR ".join(self.entry["versions"]["unaffected"]), 126 | ) 127 | ) 128 | if len(advisory["aliases"]) > 0: 129 | table.append( 130 | ( 131 | "Aliases", 132 | ", ".join( 133 | Entry._md_autolink_advisory_id(advisory_id) 134 | for advisory_id in advisory["aliases"] 135 | ), 136 | ) 137 | ) 138 | if len(advisory["related"]) > 0: 139 | table.append( 140 | ( 141 | "Related Advisories", 142 | ", ".join( 143 | Entry._md_autolink_advisory_id(advisory_id) 144 | for advisory_id in advisory["related"] 145 | ), 146 | ) 147 | ) 148 | 149 | table_parts = [] 150 | for row in table: 151 | table_parts.append("| ") 152 | if row[0] is not None: 153 | table_parts.append(row[0]) 154 | table_parts.append(" | ") 155 | if row[1] is not None: 156 | table_parts.append(row[1]) 157 | else: 158 | table_parts.append("n/a") 159 | table_parts.append(" |\n") 160 | 161 | return "".join(table_parts) 162 | else: 163 | # There is no advisory. 164 | # This occurs when a yanked version is detected. 165 | 166 | name = self.entry["package"]["name"] 167 | return f"""{self.id()} is yanked. 168 | Switch to a different version of `{name}` to resolve this issue. 169 | """ 170 | 171 | @classmethod 172 | def _md_autolink_advisory_id(cls, advisory_id: str) -> str: 173 | """ 174 | If a supported advisory format, such as GHSA- is detected, return a markdown link. 175 | Otherwise return the ID as text. 176 | """ 177 | 178 | if advisory_id.startswith("GHSA-"): 179 | return f"[{advisory_id}](https://github.com/advisories/{advisory_id})" 180 | if advisory_id.startswith("CVE-"): 181 | return f"[{advisory_id}](https://nvd.nist.gov/vuln/detail/{advisory_id})" 182 | if advisory_id.startswith("RUSTSEC-"): 183 | return f"[{advisory_id}](https://rustsec.org/advisories/{advisory_id})" 184 | return advisory_id 185 | 186 | def format_as_markdown(self) -> str: 187 | advisory = self.entry.get("advisory", None) 188 | 189 | if advisory: 190 | entry_table = self._entry_table() 191 | # Replace the @ with a ZWJ to avoid triggering markdown autolinks 192 | # Otherwise GitHub will interpret the @ as a mention 193 | description = advisory["description"].replace("@", "@\u200d") 194 | 195 | md = f"""## {self.entry_type.icon()} {advisory['id']}: {advisory['title']} 196 | 197 | {entry_table} 198 | 199 | {description} 200 | """ 201 | return md 202 | else: 203 | # There is no advisory. 204 | # This occurs when a yanked version is detected. 205 | 206 | name = self.entry["package"]["name"] 207 | return f"""## {self.entry_type.icon()} {self.id()} is yanked. 208 | 209 | Switch to a different version of `{name}` to resolve this issue. 210 | """ 211 | 212 | def format_as_issue(self, labels: List[str], assignees: List[str]) -> Issue: 213 | advisory = self.entry.get("advisory", None) 214 | 215 | if advisory: 216 | entry_table = self._entry_table() 217 | 218 | title = f"{self.id()}: {advisory['title']}" 219 | body = f"""{entry_table} 220 | 221 | {advisory['description']}""" 222 | return Issue( 223 | title=title, 224 | labels=labels, 225 | assignees=assignees, 226 | body=body, 227 | rustsec_id=self.id(), 228 | ) 229 | else: 230 | # There is no advisory. 231 | # This occurs when a yanked version is detected. 232 | 233 | name = self.entry["package"]["name"] 234 | title = f"{self.id()} is yanked" 235 | body = ( 236 | f"""Switch to a different version of `{name}` to resolve this issue.""" 237 | ) 238 | return Issue( 239 | title=title, 240 | labels=labels, 241 | assignees=assignees, 242 | body=body, 243 | rustsec_id=self.id(), 244 | ) 245 | 246 | 247 | class GitHubClient: 248 | """Basic client for getting the last diff and creating/closing issues.""" 249 | 250 | existing_issues: List[Dict[str, Any]] = [] 251 | base_url = "https://api.github.com/" 252 | repos_url = f"{base_url}repos/" 253 | 254 | def __init__(self) -> None: 255 | self.repo = os.getenv("REPO") 256 | self.token = os.getenv("INPUT_TOKEN") 257 | self.issues_url = f"{self.repos_url}{self.repo}/issues" 258 | self.issue_headers = { 259 | "Content-Type": "application/json", 260 | "Authorization": f"token {self.token}", 261 | } 262 | # Retrieve the existing repo issues now so we can easily check them later. 263 | self._get_existing_issues() 264 | 265 | debug("Existing issues:") 266 | for issue in self.existing_issues: 267 | debug(f"* {issue['title']}") 268 | 269 | def _get_existing_issues(self, page: int = 1) -> None: 270 | """Populate the existing issues list.""" 271 | params: Dict[str, Union[str, int]] = { 272 | "per_page": 100, 273 | "page": page, 274 | "state": "open", 275 | } 276 | debug(f"Fetching existing issues from GitHub: {page=}") 277 | list_issues_request = requests.get( 278 | self.issues_url, headers=self.issue_headers, params=params, timeout=TIMEOUT 279 | ) 280 | if list_issues_request.status_code == 200: 281 | self.existing_issues.extend( 282 | [ 283 | issue 284 | for issue in list_issues_request.json() 285 | if issue["title"].startswith("RUSTSEC-") 286 | or issue["title"].startswith("Crate ") 287 | ] 288 | ) 289 | links = list_issues_request.links 290 | if "next" in links: 291 | self._get_existing_issues(page + 1) 292 | 293 | def create_issue(self, issue: Issue) -> Optional[int]: 294 | """Create a dict containing the issue details and send it to GitHub.""" 295 | title = issue.title 296 | debug(f"Creating issue: {title=}") 297 | 298 | # Check if the current issue already exists - if so, skip it. 299 | # The below is a simple and imperfect check based on the issue title. 300 | for existing_issue in self.existing_issues: 301 | if existing_issue["title"].startswith(issue.rustsec_id): 302 | if ( 303 | existing_issue["title"] == issue.title 304 | and existing_issue["body"] == issue.body 305 | ): 306 | print(f"Skipping {issue.rustsec_id} - already exists.") 307 | return None 308 | else: 309 | print(f"Update existing {issue.rustsec_id}.") 310 | body = {"title": title, "body": issue.body} 311 | update_request = requests.patch( 312 | existing_issue["url"], 313 | headers=self.issue_headers, 314 | data=json.dumps(body), 315 | timeout=TIMEOUT, 316 | ) 317 | return update_request.status_code 318 | 319 | debug( 320 | f"""No existing issue found for "{issue.rustsec_id}". Creating new issue.""" 321 | ) 322 | 323 | new_issue_body = {"title": title, "body": issue.body, "labels": issue.labels} 324 | 325 | # We need to check if any assignees/milestone specified exist, otherwise issue creation will fail. 326 | valid_assignees = [] 327 | for assignee in issue.assignees: 328 | assignee_url = f"{self.repos_url}{self.repo}/assignees/{assignee}" 329 | assignee_request = requests.get( 330 | url=assignee_url, 331 | headers=self.issue_headers, 332 | timeout=TIMEOUT, 333 | ) 334 | if assignee_request.status_code == 204: 335 | valid_assignees.append(assignee) 336 | else: 337 | print(f"Assignee {assignee} does not exist! Dropping this assignee!") 338 | new_issue_body["assignees"] = valid_assignees 339 | 340 | new_issue_request = requests.post( 341 | url=self.issues_url, 342 | headers=self.issue_headers, 343 | data=json.dumps(new_issue_body), 344 | timeout=TIMEOUT, 345 | ) 346 | 347 | return new_issue_request.status_code 348 | 349 | def close_issue(self, issue: Dict[str, Any]) -> int: 350 | body = {"state": "closed"} 351 | close_request = requests.patch( 352 | issue["url"], 353 | headers=self.issue_headers, 354 | data=json.dumps(body), 355 | timeout=TIMEOUT, 356 | ) 357 | return close_request.status_code 358 | 359 | 360 | def create_summary(data: Dict[str, Any]) -> str: 361 | res = [] 362 | 363 | # Collect summary information 364 | num_vulns: int = data["vulnerabilities"]["count"] 365 | num_warnings: int = 0 366 | num_warning_types: dict[str, int] = {} 367 | for warning_type, warnings in data["warnings"].items(): 368 | num_warnings += len(warnings) 369 | num_warning_types[warning_type] = len(warnings) 370 | 371 | if num_vulns == 0: 372 | res.append("No vulnerabilities found.") 373 | elif num_vulns == 1: 374 | res.append("1 vulnerability found.") 375 | else: 376 | res.append(f"{num_vulns} vulnerabilities found.") 377 | 378 | if num_warnings == 0: 379 | res.append("No warnings found.") 380 | elif num_warnings == 1: 381 | res.append("1 warning found.") 382 | else: 383 | desc = ", ".join( 384 | f"{count}x {warning_type}" 385 | for warning_type, count in num_warning_types.items() 386 | ) 387 | res.append(f"{num_warnings} warnings found ({desc}).") 388 | return " ".join(res) 389 | 390 | 391 | def create_entries(data: Dict[str, Any]) -> List[Entry]: 392 | entries = [] 393 | 394 | for vuln in data["vulnerabilities"]["list"]: 395 | entries.append(Entry(vuln, EntryType.ERROR)) 396 | 397 | for warning_type, warnings in data["warnings"].items(): 398 | for warning in warnings: 399 | entries.append(Entry(warning, EntryType.WARNING, warning_type=warning_type)) 400 | return entries 401 | 402 | 403 | def run() -> None: 404 | # Process ignore list of Rustsec IDs 405 | ignore_args = [] 406 | ignores = os.environ["INPUT_IGNORE"].split(",") 407 | for ign in ignores: 408 | if ign.strip() != "": 409 | ignore_args.append("--ignore") 410 | ignore_args.append(ign) 411 | 412 | extra_args = [] 413 | if os.environ["INPUT_DENY_WARNINGS"] == "true": 414 | extra_args.append("--deny") 415 | extra_args.append("warnings") 416 | 417 | if os.environ["INPUT_FILE"] != "": 418 | extra_args.append("--file") 419 | extra_args.append(os.environ["INPUT_FILE"]) 420 | 421 | working_directory = None 422 | if os.environ["INPUT_WORKING_DIRECTORY"] != "": 423 | working_directory = os.environ["INPUT_WORKING_DIRECTORY"] 424 | 425 | audit_cmd = ["cargo", "audit", "--json"] + extra_args + ignore_args 426 | debug(f"Running command: {audit_cmd}") 427 | completed = subprocess.run( 428 | audit_cmd, 429 | cwd=working_directory, 430 | capture_output=True, 431 | text=True, 432 | check=False, 433 | ) 434 | debug(f"Command return code: {completed.returncode}") 435 | debug(f"Command output: {completed.stdout}") 436 | debug(f"Command error: {completed.stderr}") 437 | try: 438 | data = json.loads(completed.stdout) 439 | except json.decoder.JSONDecodeError as _: 440 | error( 441 | f"cargo audit did not produce any JSON output. Exit code: {completed.returncode}" 442 | ) 443 | group( 444 | "cargo audit output", 445 | f"""stdout:\n{completed.stdout}\n\n\nstderr:\n{completed.stderr}""", 446 | ) 447 | 448 | sys.exit(2) 449 | 450 | summary = create_summary(data) 451 | entries = create_entries(data) 452 | print(f"{len(entries)} entries found.") 453 | 454 | if os.environ["INPUT_DENY_WARNINGS"] == "true": 455 | for entry in entries: 456 | entry.entry_type = EntryType.ERROR 457 | 458 | # Print a summary of the found issues 459 | with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as step_summary: 460 | step_summary.write("# Rustsec Advisories\n\n") 461 | step_summary.write(summary) 462 | step_summary.write("\n") 463 | for entry in entries: 464 | step_summary.write(entry.format_as_markdown()) 465 | step_summary.write("\n") 466 | print("Posted step summary") 467 | 468 | if os.environ["INPUT_CREATE_ISSUES"] == "true": 469 | # Post each entry as an issue to GitHub 470 | gh_client = GitHubClient() 471 | print("Create/Update issues") 472 | for entry in entries: 473 | issue = entry.format_as_issue(labels=[], assignees=[]) 474 | gh_client.create_issue(issue) 475 | 476 | # Close all issues which no longer exist 477 | # First remove all still existing issues, then close the remaining ones 478 | num_existing_issues = len(gh_client.existing_issues) 479 | for entry in entries: 480 | for ex_issue in gh_client.existing_issues: 481 | if ex_issue["title"].startswith(entry.id()): 482 | gh_client.existing_issues.remove(ex_issue) 483 | num_old_issues = len(gh_client.existing_issues) 484 | print( 485 | f"Close old issues: {num_existing_issues} exist, {len(entries)} current issues, {num_old_issues} old issues to close." 486 | ) 487 | for ex_issue in gh_client.existing_issues: 488 | gh_client.close_issue(ex_issue) 489 | 490 | # Fail if any error exists 491 | if any(entry.entry_type == EntryType.ERROR for entry in entries): 492 | sys.exit(1) 493 | else: 494 | sys.exit(0) 495 | -------------------------------------------------------------------------------- /imgs/audit-summary.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/actions-rust-lang/audit/11b13924b83cc74fff46a07af601d6933e73717a/imgs/audit-summary.png --------------------------------------------------------------------------------