# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
import json
import os
import re
import subprocess
from copy
import deepcopy
from pathlib
import Path
from typing
import Any, Optional
import mozpack.path
as mozpath
from mozfile
import which
from mozlint
import result
from mozlint.pathutils
import expand_exclusions
from packaging.version
import Version
def to_str_affected(info: dict[str, Any]) -> str:
"""Converts affected platform info into a string.
Args:
info: JSON dictionary containing affected platform info.
Returns:
A string representing the vulnerability
's affected platforms.
"""
affected = info.get(
"affected")
if not affected:
return ""
lines = []
for key, label
in [
(
"arch",
"architectures"),
(
"os",
"operating systems"),
(
"functions",
"functions"),
]:
if affected.get(key):
lines.append(f
"\nAffected {label}: {json.dumps(affected[key], indent=2)}")
return "".join(lines)
def to_str_versions(info: dict[str, Any]) -> str:
"""Convert versioning info into a string.
Args:
info: JSON dictionary containing vulnerability versioning info.
Returns:
A string summarizing the vulnerability
's version info.
"""
versions = info.get(
"versions")
if not versions:
return ""
lines = []
for key
in (
"patched",
"unaffected"):
if versions.get(key):
lines.append(
f
"\n{key.capitalize()} versions: {json.dumps(versions[key], indent=2)}"
)
return "".join(lines)
def to_str_advisory(advisory: dict[str, Any]) -> tuple[str, dict[str, Any]]:
"""Create a string summary of the "advisory
" section in a vulnerability description.
Args:
advisory: a dictionary describing an advisory.
Returns:
If the advisory
is empty, returns a tuple containing a string describing the
most important parts of the advisory
and a dictionary containing the
leftover parts.
If the advisory section
is empty, the returned string
and dictionary will both
be empty.
"""
message =
""
if advisory:
advisory = deepcopy(advisory)
message = f
"{advisory.pop('title')}\nPackage: {advisory.pop('package')}\nID: {advisory.pop('id')}"
cvss = advisory.pop(
"cvss",
False)
if cvss:
message += f
"\nCVSS: {cvss}"
message += (
f
"\nReport date: {advisory.pop('date')}\n{advisory.pop('description')}"
)
url = advisory.pop(
"url",
False)
if url:
message += f
"\nURL: {url}"
return (message, advisory)
def dump_leftover_advisory(leftovers: dict[str, Any]) -> str:
"""Convert remaining advisory info to a string.
Args:
leftovers: A JSON dictionary containing the remaining advisory data.
Returns:
A string containing the remaining advisory data.
"""
message =
""
if leftovers:
message += f
"Advisory metadata: {json.dumps(leftovers, indent=2)}"
return message
def build_message(kind: str, info: dict[str, Any], verbose: bool) -> str:
"""Build a useful message describing a vulnerability.
Args:
kind: A string representing the category of the vulnerability.
info: JSON dictionary representation of a vulnerability
from the cargo-audit output.
verbose: A bool indicating
if we should print more information.
Returns:
A string summarizing the vulnerability.
"""
package = info[
"package"]
message = f
"Crate depends on a {kind} version of {package['name']}."
advisory = info.get(
"advisory",
False)
leftover_advisory =
None
if advisory:
(temp, leftover_advisory) = to_str_advisory(advisory)
message += f
"\n\nAdvisory:\n{temp}"
message += to_str_versions(info)
message += to_str_affected(info)
if verbose
and leftover_advisory
is not None:
message +=
"\n" + dump_leftover_advisory(leftover_advisory)
return message +
"\n\n" + f
"Package info: {json.dumps(package, indent=2)}"
def build_issue(config, path, message, level) -> Any:
return result.from_config(
config,
**{
"path": path,
"message": message,
"lineno": -
1,
"column": -
1,
"level": level,
},
)
def run_process(args: list[str]) -> str:
try:
result = subprocess.run(
args,
capture_output=
True,
text=
True,
check=
False,
)
if result.returncode !=
0:
# Allow failure if output exists (e.g., vulnerabilities found)
if result.stdout:
return result.stdout
else:
raise RuntimeError(
f
"Command failed: {' '.join(args)}\n"
f
"Exit code: {result.returncode}\n"
f
"stdout:\n{result.stdout}\n"
f
"stderr:\n{result.stderr}"
)
return result.stdout
except FileNotFoundError
as e:
raise RuntimeError(
f
"Executable not found when running command: {' '.join(args)}\n"
f
"Original error: {e}\n"
"Make sure the executable is installed and in your system PATH."
)
def is_excluded(message: str, exclusions: list[str]) -> bool:
return any(exclusion
in message
for exclusion
in exclusions)
def lint(paths, config, log, **lintargs) -> list[Any]:
results = []
files = list(expand_exclusions(paths, config, lintargs[
"root"]))
args = [
"cargo-audit",
"audit",
"--json"]
for f
in files:
tail = [
"--file", f]
raw = run_process(args + tail)
try:
cargo_audit = json.loads(raw)
except json.JSONDecodeError:
log.warn(f
"Could not parse cargo-audit output for file: {f}")
log.debug(f
"cargo-audit output for {f}: {raw}")
else:
show_verbose = lintargs.get(
"show_verbose",
False)
vulnerabilities = cargo_audit[
"vulnerabilities"]
if vulnerabilities[
"found"]:
for vulnerability
in vulnerabilities[
"list"]:
message = build_message(
"vulnerable", vulnerability, show_verbose)
exclusions = config.get(
"exclude-error", [])
if not is_excluded(message, exclusions):
results.append(build_issue(config, f, message,
"error"))
warning_categories = cargo_audit[
"warnings"]
for kind, warnings
in warning_categories.items():
for warning
in warnings:
message = build_message(kind, warning, show_verbose)
exclusions = config.get(
"exclude-error", [])
if not is_excluded(message, exclusions):
results.append(build_issue(config, f, message,
"warning"))
return results
def get_audit_version() -> str:
"""Get the version number of the cargo-audit installation.
Returns:
str: A string representing the cargo-audit version.
"""
return run_process([
"cargo-audit",
"--version"]).strip().split()[
1]
def get_rust_version() -> Optional[str]:
"""Get the version number from rustc.
Returns:
str: A string representing the rust version
or None if it couldn
't be
detected.
"""
proc = subprocess.run(
[
"rustc",
"--version"], capture_output=
True, text=
True, check=
False
)
if proc.returncode !=
0:
return
m = re.match(r
"^rustc (\S+) \(.+\)$", proc.stdout)
if not m:
return
version = m.group(
1)
if "-" in version:
version = version.rsplit(
"-",
1)[
0]
return version
def setup(root, log, **lintargs) -> int:
min_rust_version =
"1.85.0"
rust_version = get_rust_version()
if not rust_version
or Version(rust_version) < Version(min_rust_version):
version_was = f
"{rust_version} was" if rust_version
else "was not"
log.error(
f
"cargo-audit requires at least Rust {min_rust_version}, but Rust {version_was} detected!"
)
return 1
binary = which(
"cargo-audit")
with Path(__file__).parent.joinpath(
"cargo-audit_version.txt").open()
as f:
desired_version = f.read().strip()
installed_version =
False
if binary
and os.path.exists(binary):
binary = mozpath.normsep(binary)
installed_version = get_audit_version()
if not installed_version
or installed_version != desired_version:
output = run_process([
"cargo",
"install",
"--locked",
"--version",
desired_version,
"--color",
"never",
"cargo-audit",
])
if not which(
"cargo-audit")
or get_audit_version() != desired_version:
log.error(f
"Could not install cargo-audit:\n{output}")
return 1
return 0