mirror of
https://github.com/actions-rust-lang/audit.git
synced 2025-12-27 01:43:48 -05:00
Initial Version
This commit is contained in:
31
.github/autotag-releases.yml
vendored
Normal file
31
.github/autotag-releases.yml
vendored
Normal file
@@ -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}"
|
||||
11
.github/dependabot.yml
vendored
Normal file
11
.github/dependabot.yml
vendored
Normal file
@@ -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"
|
||||
14
.github/workflows/audit.yml
vendored
Normal file
14
.github/workflows/audit.yml
vendored
Normal file
@@ -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
|
||||
32
.pre-commit-config.yaml
Normal file
32
.pre-commit-config.yaml
Normal file
@@ -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
|
||||
12
CHANGELOG.md
Normal file
12
CHANGELOG.md
Normal file
@@ -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
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -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.
|
||||
56
README.md
Normal file
56
README.md
Normal file
@@ -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:
|
||||
|
||||

|
||||
|
||||
## 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
|
||||
56
action.yml
Normal file
56
action.yml
Normal file
@@ -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 }}
|
||||
316
audit.py
Normal file
316
audit.py
Normal file
@@ -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)
|
||||
BIN
imgs/audit-summary.png
Normal file
BIN
imgs/audit-summary.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
Reference in New Issue
Block a user