commit f5194fc11b2e2c0b33922c1ece3f42ed5dc353b6 Author: Jonas Bushart Date: Tue Aug 9 23:01:32 2022 +0200 Initial Version diff --git a/.github/autotag-releases.yml b/.github/autotag-releases.yml new file mode 100644 index 0000000..a0b5f30 --- /dev/null +++ b/.github/autotag-releases.yml @@ -0,0 +1,31 @@ +# Create tags with only major or major.minor version +# This runs for every created release. Releases are expected to use vmajor.minor.patch as tags. + +name: Autotag Release +on: + release: + types: [released] + workflow_dispatch: +permissions: read-all + +jobs: + autotag_release: + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + - name: Get version from tag + id: tag_name + run: | + echo ::set-output name=current_version::${GITHUB_REF#refs/tags/} + shell: bash + - name: Create and push tags + run: | + MINOR="$(echo -n ${{ steps.tag_name.outputs.current_version }} | cut -d. -f1-2)" + MAJOR="$(echo -n ${{ steps.tag_name.outputs.current_version }} | cut -d. -f1)" + git tag -f "${MINOR}" + git tag -f "${MAJOR}" + git push -f origin "${MINOR}" + git push -f origin "${MAJOR}" diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..4eb1d53 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..c25d8e0 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,14 @@ +on: [workflow_dispatch, push] + +jobs: + audit: + runs-on: ubuntu-latest + # permissions: + # issues: write + steps: + - uses: actions/checkout@v3 + - uses: jonasbb/test-actions@master + name: Audit Rust Dependencies + with: + ignore: RUSTSEC-2020-0036 + createIssues: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..49fd184 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +repos: + - repo: https://github.com/psf/black + rev: 22.6.0 + hooks: + - id: black + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-ast + - id: check-case-conflict + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/PyCQA/isort + rev: 5.10.1 + # https://github.com/psf/black/blob/main/docs/guides/using_black_with_other_tools.md + hooks: + - id: isort + args: ["--profile=black"] + - repo: https://github.com/asottile/pyupgrade + rev: v2.37.3 + hooks: + - id: pyupgrade + args: ["--py37-plus"] + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v0.971 + hooks: + - id: mypy + additional_dependencies: + - types-requests diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7ea9e8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0] - 2022-08-09 + +Initial Version diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..086716c --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2022 Rust Actions +Copyright (c) 2020 Alastair Mooney + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..dee7731 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Audit Rust dependencies using the RustSec Advisory DB + +Audit your Rust dependencies using [cargo audit] and the [RustSec Advisory DB]. The action creates a summary with all vulnerabilieties. It can create issues for each of the found vulnerabilities. + +Execution Summary: + +![The action reports any audit results.](./imgs/audit-summary.png) + +## Example workflow + +```yaml +name: "Audit Dependencies" +on: + push: + paths: + - '**/Cargo.toml' + - '**/Cargo.lock' + schedule: + - cron: '0 0 * * *' + +permissions: read-all + +jobs: + audit: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/checkout@v3 + - uses: actions-rust-lang/audit@v1 + name: Audit Rust Dependencies + with: + # Comma separated list of issues to ignore + ignore: RUSTSEC-2020-0036 +``` + +## Inputs + +All inputs are optional. +Consider adding a [`audit.toml` configuration file] to your repository for further configurations. + +| Name | Description | Default | +| -------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | +| `TOKEN` | The GitHub access token to allow us to retrieve, create and update issues (automatically set). | `github.token` | +| `denyWarnings` | Any warnings generated will be treated as an error and fail the action. | false | +| `ignore` | A comma separated list of Rustsec IDs to ignore. | | +| `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'` | + +## License + +The scripts and documentation in this project are released under the [MIT License]. + +[MIT License]: LICENSE +[cargo audit]: https://github.com/RustSec/rustsec/tree/main/cargo-audit +[RustSec Advisory DB]: https://rustsec.org/advisories/ +[`audit.toml` configuration file]: https://github.com/rustsec/rustsec/blob/main/cargo-audit/audit.toml.example diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..ea8a249 --- /dev/null +++ b/action.yml @@ -0,0 +1,56 @@ +name: Audit Rust Dependencies +description: | + Audit Rust dependencies for vulnerabilities or outdated dependencies. +branding: + icon: "play" + color: "gray-dark" + +inputs: + TOKEN: + description: "The GitHub access token to allow us to retrieve, create and update issues (automatically set)" + required: false + default: ${{ github.token }} + denyWarnings: + description: "Any warnings generated will be treated as an error and fail the action" + required: false + default: "false" + ignore: + description: "A comma separated list of Rustsec IDs to ignore" + required: false + default: "" + createIssues: + description: Create/Update issues for each found vulnerability. + required: false + default: "${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }}" + # TODO: Add flag for controlling issues + # TODO: Only open issues for main/master but not for pull requests + +runs: + using: composite + steps: + - uses: actions/cache@v3 + id: cache + with: + path: | + ${{ env.CARGO_HOME }}/.cargo/bin/cargo-audit* + ${{ env.CARGO_HOME }}/.cargo/.crates.toml + ${{ env.CARGO_HOME }}/.cargo/.crates2.json + key: cargo-audit-v0.17.0 + + - name: Install cargo-audit + if: steps.cache.outputs.cache-hit != 'true' + # Update both this version number and the cache key + run: cargo install cargo-audit --vers 0.17.0 + shell: bash + + - run: | + import audit + audit.run() + shell: python + env: + INPUT_CREATE_ISSUES: ${{ inputs.createIssues }} + INPUT_DENY_WARNINGS: ${{ inputs.denyWarnings }} + INPUT_IGNORE: ${{ inputs.ignore }} + INPUT_TOKEN: ${{ inputs.TOKEN }} + PYTHONPATH: ${{ github.action_path }} + REPO: ${{ github.repository }} diff --git a/audit.py b/audit.py new file mode 100644 index 0000000..a7fb738 --- /dev/null +++ b/audit.py @@ -0,0 +1,316 @@ +import enum +import json +import os +import subprocess +import sys +from typing import Any, Dict, List, Optional, Union + +import requests + +# GitHub API CLient copied and adapted from +# https://github.com/alstr/todo-to-issue-action/blob/25c80e9c4999d107bec208af49974d329da26370/main.py +# Originally licensed under MIT license + + +class Issue: + """Basic Issue model for collecting the necessary info to send to GitHub.""" + + def __init__( + self, + title: str, + labels: List[str], + assignees: List[str], + body: str, + rustsec_id: str, + ) -> None: + self.title = title + self.labels = labels + self.assignees = assignees + self.body = body + + self.rustsec_id = rustsec_id + + +class EntryType(enum.Enum): + ERROR = "error" + WARNING = "warning" + + def icon(self) -> str: + if self == EntryType.ERROR: + return "🛑" + elif self == EntryType.WARNING: + return "⚠️" + else: + return "" + + +class Entry: + entry: Dict[str, Any] + entry_type: EntryType + warning_type: Optional[str] = None + + def __init__( + self, + entry: Dict[str, Any], + entry_type: EntryType, + warning_type: Optional[str] = None, + ): + self.entry = entry + self.entry_type = entry_type + self.warning_type = warning_type + + def _entry_table(self) -> str: + advisory = self.entry["advisory"] + + if self.warning_type is None: + warning = "" + else: + warning = f"\n| Warning | {self.warning_type} |" + unaffected = " OR ".join(self.entry["versions"]["unaffected"]) + if unaffected != "": + unaffected = f"\n| Unaffected Versions | `{unaffected}` |" + patched = " OR ".join(self.entry["versions"]["patched"]) + if patched == "": + patched = "n/a" + else: + patched = f"`{patched}`" + table = f"""| Details | | +| --- | --- | +| Package | `{advisory['package']}` | +| Version | `{self.entry['package']['version']}` |{warning} +| URL | <{advisory['url']}> | +| Patched Versions | {patched} |{unaffected} +""" + return table + + def format_as_markdown(self) -> str: + advisory = self.entry["advisory"] + + entry_table = self._entry_table() + md = f"""## {self.entry_type.icon()} {advisory['id']}: {advisory['title']} + +{entry_table} + +{advisory['description']} +""" + return md + + def format_as_issue(self, labels: List[str], assignees: List[str]) -> Issue: + advisory = self.entry["advisory"] + + entry_table = self._entry_table() + + title = f"{advisory['id']}: {advisory['title']}" + body = f"""{entry_table} + +{advisory['description']}""" + return Issue( + title=title, + labels=labels, + assignees=assignees, + body=body, + rustsec_id=advisory["id"], + ) + + +class GitHubClient: + """Basic client for getting the last diff and creating/closing issues.""" + + existing_issues: List[Dict[str, Any]] = [] + base_url = "https://api.github.com/" + repos_url = f"{base_url}repos/" + + def __init__(self) -> None: + self.repo = os.getenv("REPO") + self.token = os.getenv("INPUT_TOKEN") + self.issues_url = f"{self.repos_url}{self.repo}/issues" + self.issue_headers = { + "Content-Type": "application/json", + "Authorization": f"token {self.token}", + } + # Retrieve the existing repo issues now so we can easily check them later. + self._get_existing_issues() + + def _get_existing_issues(self, page: int = 1) -> None: + """Populate the existing issues list.""" + params: Dict[str, Union[str, int]] = { + "per_page": 100, + "page": page, + "state": "open", + } + list_issues_request = requests.get( + self.issues_url, headers=self.issue_headers, params=params + ) + print(f"DBG: {list_issues_request.status_code=}") + if list_issues_request.status_code == 200: + self.existing_issues.extend( + [ + issue + for issue in list_issues_request.json() + if issue["title"].startswith("RUSTSEC-") + ] + ) + links = list_issues_request.links + if "next" in links: + self._get_existing_issues(page + 1) + + def create_issue(self, issue: Issue) -> Optional[int]: + """Create a dict containing the issue details and send it to GitHub.""" + title = issue.title + + # Check if the current issue already exists - if so, skip it. + # The below is a simple and imperfect check based on the issue title. + for existing_issue in self.existing_issues: + if existing_issue["title"].startswith(issue.rustsec_id): + if ( + existing_issue["title"] == issue.title + and existing_issue["body"] == issue.body + ): + print(f"Skipping {issue.rustsec_id} - already exists.") + return None + else: + print(f"Update existing {issue.rustsec_id}.") + body = {"title": title, "body": issue.body} + update_request = requests.patch( + existing_issue["url"], + headers=self.issue_headers, + data=json.dumps(body), + ) + return update_request.status_code + + new_issue_body = {"title": title, "body": issue.body, "labels": issue.labels} + + # We need to check if any assignees/milestone specified exist, otherwise issue creation will fail. + valid_assignees = [] + for assignee in issue.assignees: + assignee_url = f"{self.repos_url}{self.repo}/assignees/{assignee}" + assignee_request = requests.get( + url=assignee_url, headers=self.issue_headers + ) + if assignee_request.status_code == 204: + valid_assignees.append(assignee) + else: + print(f"Assignee {assignee} does not exist! Dropping this assignee!") + new_issue_body["assignees"] = valid_assignees + + new_issue_request = requests.post( + url=self.issues_url, + headers=self.issue_headers, + data=json.dumps(new_issue_body), + ) + + return new_issue_request.status_code + + def close_issue(self, issue: Dict[str, Any]) -> int: + body = {"state": "closed"} + close_request = requests.patch( + issue["url"], headers=self.issue_headers, data=json.dumps(body) + ) + return close_request.status_code + + +def create_summary(data: Dict[str, Any]) -> str: + res = [] + + # Collect summary information + num_vulns: int = data["vulnerabilities"]["count"] + num_warnings: int = 0 + num_warning_types: dict[str, int] = {} + for warning_type, warnings in data["warnings"].items(): + num_warnings += len(warnings) + num_warning_types[warning_type] = len(warnings) + + if num_vulns == 0: + res.append("No vulnerabilities found.") + elif num_vulns == 1: + res.append("1 vulnerability found.") + else: + res.append(f"{num_vulns} vulnerabilities found.") + + if num_warnings == 0: + res.append("No warnings found.") + elif num_warnings == 1: + res.append("1 warning found.") + else: + desc = ", ".join( + f"{count}x {warning_type}" + for warning_type, count in num_warning_types.items() + ) + res.append(f"{num_warnings} warnings found ({desc}).") + return " ".join(res) + + +def create_entries(data: Dict[str, Any]) -> List[Entry]: + entries = [] + + for vuln in data["vulnerabilities"]["list"]: + entries.append(Entry(vuln, EntryType.ERROR)) + + for warning_type, warnings in data["warnings"].items(): + for warning in warnings: + entries.append(Entry(warning, EntryType.WARNING, warning_type=warning_type)) + return entries + + +def run() -> None: + # Process ignore list of Rustsec IDs + ignore_args = [] + ignores = os.environ["INPUT_IGNORE"].split(",") + for ign in ignores: + if ign.strip() != "": + ignore_args.append("--ignore") + ignore_args.append(ign) + + completed = subprocess.run( + ["cargo", "audit", "--json"] + ignore_args, + capture_output=True, + text=True, + check=False, + ) + data = json.loads(completed.stdout) + + summary = create_summary(data) + entries = create_entries(data) + print(f"{len(entries)} entries found.") + + if os.environ["INPUT_DENY_WARNINGS"] == "true": + for entry in entries: + entry.entry_type = EntryType.ERROR + + # Print a summary of the found issues + with open(os.environ["GITHUB_STEP_SUMMARY"], "a") as step_summary: + step_summary.write("# Rustsec Advisories\n\n") + step_summary.write(summary) + step_summary.write("\n") + for entry in entries: + step_summary.write(entry.format_as_markdown()) + step_summary.write("\n") + print("Posted step summary") + + if os.environ["INPUT_CREATE_ISSUES"] == "true": + # Post each entry as an issue to GitHub + gh_client = GitHubClient() + print("Create/Update issues") + for entry in entries: + issue = entry.format_as_issue(labels=[], assignees=[]) + gh_client.create_issue(issue) + + # Close all issues which no longer exist + # First remove all still existing issues, then close the remaining ones + num_existing_issues = len(gh_client.existing_issues) + for entry in entries: + for ex_issue in gh_client.existing_issues: + if ex_issue["title"].startswith(entry.entry["advisory"]["id"]): + gh_client.existing_issues.remove(ex_issue) + num_old_issues = len(gh_client.existing_issues) + print( + f"Close old issues: {num_existing_issues} exist, {len(entries)} current issues, {num_old_issues} old issues to close." + ) + for ex_issue in gh_client.existing_issues: + gh_client.close_issue(ex_issue) + + # Fail if any error exists + if any(entry.entry_type == EntryType.ERROR for entry in entries): + sys.exit(1) + else: + sys.exit(0) diff --git a/imgs/audit-summary.png b/imgs/audit-summary.png new file mode 100644 index 0000000..8275a47 Binary files /dev/null and b/imgs/audit-summary.png differ